2026-09-07 23:02:55 +08:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-09-07 14:54:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Banner,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Checkbox,
|
|
|
|
|
|
CheckboxGroup,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Popover,
|
2026-09-07 23:31:23 +08:00
|
|
|
|
Pagination,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
Select,
|
|
|
|
|
|
Table,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
TextArea,
|
|
|
|
|
|
Toast,
|
2026-09-10 14:09:39 +08:00
|
|
|
|
Typography,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
} from "@douyinfe/semi-ui-19";
|
2026-09-10 14:55:23 +08:00
|
|
|
|
import {
|
|
|
|
|
|
IconFilter,
|
|
|
|
|
|
IconSetting,
|
|
|
|
|
|
IconSort,
|
|
|
|
|
|
IconSearch,
|
|
|
|
|
|
IconClose,
|
|
|
|
|
|
IconDownload,
|
|
|
|
|
|
IconUpload,
|
|
|
|
|
|
IconRefresh,
|
|
|
|
|
|
} from "@douyinfe/semi-icons";
|
|
|
|
|
|
import "./AlphaPage.css";
|
2026-09-12 22:42:37 +08:00
|
|
|
|
import { formatAlphaMetric } from "../alphaMetrics";
|
2026-09-07 14:54:20 +08:00
|
|
|
|
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
|
|
|
|
|
|
import {
|
|
|
|
|
|
api,
|
2026-09-08 10:40:49 +08:00
|
|
|
|
correlationLabels,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
formatNumber,
|
|
|
|
|
|
formatTime,
|
|
|
|
|
|
patch,
|
|
|
|
|
|
post,
|
|
|
|
|
|
queryString,
|
|
|
|
|
|
stateLabels,
|
|
|
|
|
|
stateOptions,
|
|
|
|
|
|
} from "../api";
|
2026-09-08 10:40:49 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
Account,
|
|
|
|
|
|
Alpha,
|
|
|
|
|
|
AlphaPage as Page,
|
|
|
|
|
|
Facets,
|
2026-09-09 20:10:20 +08:00
|
|
|
|
Job,
|
2026-09-08 10:40:49 +08:00
|
|
|
|
Submission,
|
|
|
|
|
|
} from "../types";
|
2026-09-07 14:54:20 +08:00
|
|
|
|
import { AlphaDetail } from "../components/AlphaDetail";
|
2026-09-08 10:40:49 +08:00
|
|
|
|
import { AlphaSyncDialog } from "../components/AlphaSyncDialog";
|
2026-09-08 10:06:00 +08:00
|
|
|
|
import type { PageContext, AlphaUIAction as UIAction } from "../ai/types";
|
2026-09-08 12:43:00 +08:00
|
|
|
|
import type { UIAction as WorkspaceAction } from "../ai/types";
|
2026-09-08 21:52:46 +08:00
|
|
|
|
import { SavedViews } from "../research/SavedViews";
|
2026-09-08 12:43:00 +08:00
|
|
|
|
import { sourceLabel } from "../research/SourceDetails";
|
2026-09-07 14:54:20 +08:00
|
|
|
|
|
|
|
|
|
|
const metricLabels = {
|
|
|
|
|
|
sharpe: "Sharpe",
|
|
|
|
|
|
fitness: "Fitness",
|
|
|
|
|
|
returns: "Returns",
|
|
|
|
|
|
turnover: "Turnover",
|
|
|
|
|
|
margin: "Margin",
|
|
|
|
|
|
drawdown: "Drawdown",
|
2026-09-09 19:02:37 +08:00
|
|
|
|
sub_universe_sharpe: "子股票池 Sharpe",
|
|
|
|
|
|
robust_universe_sharpe: "稳健股票池 Sharpe",
|
|
|
|
|
|
two_year_sharpe: "近两年 Sharpe",
|
|
|
|
|
|
prod_correlation: "平台生产相关性",
|
|
|
|
|
|
pnl: "IS PnL",
|
|
|
|
|
|
};
|
2026-09-10 15:31:09 +08:00
|
|
|
|
const metricColorThresholds: Record<string, [number, number, number]> = {
|
|
|
|
|
|
sharpe: [1.5, 1.0, 0.5],
|
|
|
|
|
|
fitness: [0.75, 0.5, 0.25],
|
|
|
|
|
|
returns: [0.15, 0.1, 0.05],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** Preserve the legacy list's strict thresholds; missing values have no rating. */
|
|
|
|
|
|
function metricColor(key: string, value: unknown): string | undefined {
|
|
|
|
|
|
const thresholds = metricColorThresholds[key];
|
|
|
|
|
|
if (!thresholds || typeof value !== "number" || !Number.isFinite(value))
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
if (value > thresholds[0]) return "var(--semi-color-success)";
|
|
|
|
|
|
if (value > thresholds[1]) return "var(--semi-color-info)";
|
|
|
|
|
|
if (value > thresholds[2]) return "var(--semi-color-warning)";
|
|
|
|
|
|
return "var(--semi-color-danger)";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 22:42:37 +08:00
|
|
|
|
/** Format metrics only for display; filtering and sorting still use raw values. */
|
2026-09-10 15:31:09 +08:00
|
|
|
|
function formatMetric(key: string, value: unknown): string {
|
2026-09-12 22:42:37 +08:00
|
|
|
|
const headline = formatAlphaMetric(key, value);
|
|
|
|
|
|
if (headline !== undefined) return headline;
|
2026-09-10 15:31:09 +08:00
|
|
|
|
if (key !== "margin") return formatNumber(value, 3);
|
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
|
|
|
|
? `${formatNumber(value * 10000, 2)}bps`
|
|
|
|
|
|
: "—";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-09 19:02:37 +08:00
|
|
|
|
const checkLabels = {
|
|
|
|
|
|
PENDING: "待检查",
|
|
|
|
|
|
PRE_CHECK: "预检通过",
|
|
|
|
|
|
PASS: "检查通过",
|
|
|
|
|
|
FAIL_1: "FAIL=1",
|
|
|
|
|
|
FAIL_2: "FAIL≥2",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
const initialColumns = [
|
|
|
|
|
|
"name",
|
|
|
|
|
|
"expression",
|
|
|
|
|
|
"region",
|
|
|
|
|
|
"status",
|
2026-09-09 19:02:37 +08:00
|
|
|
|
"check_type",
|
|
|
|
|
|
"failed_checks",
|
|
|
|
|
|
"tags",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
"sharpe",
|
|
|
|
|
|
"fitness",
|
|
|
|
|
|
"turnover",
|
2026-09-08 10:40:49 +08:00
|
|
|
|
"correlation",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
"research",
|
2026-09-08 12:43:00 +08:00
|
|
|
|
"source",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
];
|
|
|
|
|
|
const columnLabels: Record<string, string> = {
|
2026-09-08 12:43:00 +08:00
|
|
|
|
source: "研究来源",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
name: "Alpha",
|
|
|
|
|
|
expression: "表达式",
|
|
|
|
|
|
region: "地区 / Universe",
|
|
|
|
|
|
status: "平台状态",
|
|
|
|
|
|
sharpe: "Sharpe",
|
|
|
|
|
|
fitness: "Fitness",
|
|
|
|
|
|
returns: "Returns",
|
|
|
|
|
|
turnover: "Turnover",
|
|
|
|
|
|
margin: "Margin",
|
|
|
|
|
|
drawdown: "Drawdown",
|
|
|
|
|
|
research: "研究状态",
|
2026-09-09 19:02:37 +08:00
|
|
|
|
tags: "自定义标签",
|
|
|
|
|
|
check_type: "检查结果",
|
|
|
|
|
|
failed_checks: "失败项",
|
|
|
|
|
|
neutralization: "中性化",
|
|
|
|
|
|
synced: "同步时间",
|
|
|
|
|
|
sub_universe_sharpe: metricLabels.sub_universe_sharpe,
|
|
|
|
|
|
robust_universe_sharpe: metricLabels.robust_universe_sharpe,
|
|
|
|
|
|
two_year_sharpe: metricLabels.two_year_sharpe,
|
|
|
|
|
|
prod_correlation: metricLabels.prod_correlation,
|
|
|
|
|
|
pnl: metricLabels.pnl,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
language: "语言",
|
|
|
|
|
|
created: "创建时间",
|
2026-09-08 10:40:49 +08:00
|
|
|
|
submitted: "提交时间",
|
|
|
|
|
|
correlation: "本地自相关",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export function AlphaPage({
|
|
|
|
|
|
account,
|
|
|
|
|
|
taskPanelOpen,
|
|
|
|
|
|
version,
|
|
|
|
|
|
onTask,
|
|
|
|
|
|
onAccount,
|
2026-09-07 23:02:55 +08:00
|
|
|
|
active,
|
|
|
|
|
|
overlaySuspended,
|
|
|
|
|
|
chatOffset,
|
|
|
|
|
|
onContext,
|
|
|
|
|
|
action,
|
|
|
|
|
|
onOverlay,
|
2026-09-08 12:43:00 +08:00
|
|
|
|
onAction,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
account: Account | null;
|
|
|
|
|
|
taskPanelOpen: boolean;
|
|
|
|
|
|
version: string;
|
|
|
|
|
|
onTask: () => void;
|
|
|
|
|
|
onAccount: () => void;
|
2026-09-07 23:02:55 +08:00
|
|
|
|
active: boolean;
|
|
|
|
|
|
overlaySuspended: boolean;
|
|
|
|
|
|
chatOffset: number;
|
|
|
|
|
|
onContext: (context: PageContext) => void;
|
|
|
|
|
|
action: UIAction | null;
|
|
|
|
|
|
onOverlay: () => void;
|
2026-09-08 12:43:00 +08:00
|
|
|
|
onAction: (action: WorkspaceAction) => void;
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
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>>({});
|
2026-09-10 14:55:23 +08:00
|
|
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
|
|
|
|
const [selectedViewId, setSelectedViewId] = useState<string | null>(null);
|
2026-09-08 10:40:49 +08:00
|
|
|
|
const [submission, setSubmission] = useState<Submission>("UNSUBMITTED");
|
|
|
|
|
|
const [syncingScope, setSyncingScope] = useState<Submission | null>(null);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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);
|
2026-09-09 19:02:37 +08:00
|
|
|
|
const [tagEditor, setTagEditor] = useState<{
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
version: number;
|
|
|
|
|
|
text: string;
|
|
|
|
|
|
} | null>(null);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
const [bulkVersions, setBulkVersions] = useState<Record<string, number>>({});
|
|
|
|
|
|
const actionNonce = useRef(0);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
const [bulk, setBulk] = useState({
|
|
|
|
|
|
add_tags: "",
|
|
|
|
|
|
remove_tags: "",
|
|
|
|
|
|
state: "",
|
|
|
|
|
|
});
|
|
|
|
|
|
const [localVersion, setLocalVersion] = useState(0);
|
2026-09-07 23:31:23 +08:00
|
|
|
|
const tablePanel = useRef<HTMLElement>(null);
|
|
|
|
|
|
const [compact, setCompact] = useState(
|
|
|
|
|
|
() => matchMedia("(max-width: 760px)").matches,
|
|
|
|
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const media = matchMedia("(max-width: 760px)");
|
|
|
|
|
|
const change = () => setCompact(media.matches);
|
|
|
|
|
|
media.addEventListener("change", change);
|
|
|
|
|
|
return () => media.removeEventListener("change", change);
|
|
|
|
|
|
}, []);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (account) {
|
|
|
|
|
|
setPageSize(account.page_size);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [account?.page_size]);
|
|
|
|
|
|
const params = useMemo(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
...filters,
|
2026-09-08 10:40:49 +08:00
|
|
|
|
submission,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
sort,
|
|
|
|
|
|
direction,
|
|
|
|
|
|
limit: pageSize,
|
|
|
|
|
|
offset: (page - 1) * pageSize,
|
|
|
|
|
|
}),
|
2026-09-08 10:40:49 +08:00
|
|
|
|
[filters, submission, sort, direction, pageSize, page],
|
2026-09-07 14:54:20 +08:00
|
|
|
|
);
|
|
|
|
|
|
const query = queryString(params);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!active) return;
|
|
|
|
|
|
onContext({
|
|
|
|
|
|
page: "alphas",
|
|
|
|
|
|
alpha_id: detailId,
|
|
|
|
|
|
selected_ids: selected,
|
|
|
|
|
|
filters: Object.fromEntries(
|
|
|
|
|
|
Object.entries(params).filter(
|
|
|
|
|
|
([, value]) => value !== "" && value !== undefined && value !== null,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [active, detailId, selected, params, onContext]);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!action || actionNonce.current === action.nonce) return;
|
|
|
|
|
|
actionNonce.current = action.nonce;
|
|
|
|
|
|
if (action.type === "open_alpha") {
|
|
|
|
|
|
setDetailId(action.alpha_id);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
} else {
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setSelectedViewId(null);
|
|
|
|
|
|
setFilterOpen(false);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
const {
|
|
|
|
|
|
sort: nextSort,
|
|
|
|
|
|
direction: nextDirection,
|
|
|
|
|
|
limit: _limit,
|
|
|
|
|
|
offset: _offset,
|
2026-09-08 10:40:49 +08:00
|
|
|
|
submission: nextSubmission,
|
2026-09-07 23:02:55 +08:00
|
|
|
|
...values
|
|
|
|
|
|
} = action.filters;
|
|
|
|
|
|
const next = Object.fromEntries(
|
|
|
|
|
|
Object.entries(values)
|
|
|
|
|
|
.filter(([, v]) => v !== null && v !== undefined)
|
|
|
|
|
|
.map(([k, v]) => [k, String(v)]),
|
|
|
|
|
|
);
|
|
|
|
|
|
setDraft(next);
|
|
|
|
|
|
setFilters(next);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
setSelected([]);
|
2026-09-08 10:40:49 +08:00
|
|
|
|
if (nextSubmission === "SUBMITTED" || nextSubmission === "UNSUBMITTED")
|
|
|
|
|
|
setSubmission(nextSubmission);
|
2026-09-07 23:02:55 +08:00
|
|
|
|
if (nextSort) setSort(String(nextSort));
|
|
|
|
|
|
if (nextDirection) setDirection(String(nextDirection));
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [action, onOverlay]);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
const refresh = useCallback(() => setLocalVersion((n) => n + 1), []);
|
|
|
|
|
|
useEffect(() => {
|
2026-09-08 12:43:00 +08:00
|
|
|
|
if (!active) return;
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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();
|
2026-09-08 12:43:00 +08:00
|
|
|
|
}, [active, query, version, localVersion]);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setSelected([]);
|
2026-09-07 23:31:23 +08:00
|
|
|
|
tablePanel.current?.querySelector(".semi-table-body")?.scrollTo({ top: 0 });
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}, [query]);
|
|
|
|
|
|
function updateDraft(key: string, value: unknown) {
|
|
|
|
|
|
setDraft((previous) => ({
|
|
|
|
|
|
...previous,
|
|
|
|
|
|
[key]: value === undefined || value === null ? "" : String(value),
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
function applyFilters() {
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setFilterOpen(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
setFilters({ ...draft });
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
function clearFilters() {
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setFilterOpen(false);
|
2026-09-07 14:54:20 +08:00
|
|
|
|
setDraft({});
|
|
|
|
|
|
setFilters({});
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}
|
2026-09-08 10:40:49 +08:00
|
|
|
|
function changeSubmission(value: string) {
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setSelectedViewId(null);
|
|
|
|
|
|
setFilterOpen(false);
|
|
|
|
|
|
setDirection("desc");
|
2026-09-08 10:40:49 +08:00
|
|
|
|
setSubmission(value as Submission);
|
|
|
|
|
|
setDraft({});
|
|
|
|
|
|
setFilters({});
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
setSelected([]);
|
|
|
|
|
|
setSort(value === "SUBMITTED" ? "date_submitted" : "date_created");
|
|
|
|
|
|
}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
async function newTask(kind: string, ids: string[] = []) {
|
|
|
|
|
|
setBusy(kind);
|
|
|
|
|
|
try {
|
2026-09-09 20:10:20 +08:00
|
|
|
|
const job = await post<Job>("/sync-jobs", {
|
2026-09-08 10:40:49 +08:00
|
|
|
|
kind,
|
|
|
|
|
|
alpha_ids: ids,
|
|
|
|
|
|
...(kind === "full_sync" ? { submission: "SUBMITTED" } : {}),
|
|
|
|
|
|
});
|
2026-09-07 14:54:20 +08:00
|
|
|
|
setImporting(false);
|
|
|
|
|
|
setIdText("");
|
2026-09-09 20:10:20 +08:00
|
|
|
|
if (kind === "pnl_backfill" && job.total === 0)
|
|
|
|
|
|
Toast.success("已提交 Alpha 的 PnL 已齐全,无需补取");
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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", {
|
2026-09-07 23:02:55 +08:00
|
|
|
|
alpha_ids: Object.keys(bulkVersions),
|
|
|
|
|
|
versions: bulkVersions,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-09 19:02:37 +08:00
|
|
|
|
async function saveTags() {
|
|
|
|
|
|
if (!tagEditor) return;
|
|
|
|
|
|
setBusy("tags");
|
|
|
|
|
|
try {
|
|
|
|
|
|
await patch(`/alphas/${tagEditor.id}/research`, {
|
|
|
|
|
|
version: tagEditor.version,
|
|
|
|
|
|
tags: tagEditor.text
|
|
|
|
|
|
.split(/[,,]/)
|
|
|
|
|
|
.map((tag) => tag.trim())
|
|
|
|
|
|
.filter(Boolean),
|
|
|
|
|
|
});
|
|
|
|
|
|
setTagEditor(null);
|
|
|
|
|
|
refresh();
|
|
|
|
|
|
Toast.success("自定义标签已保存");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
Toast.error((e as Error).message);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy("");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
const columnDefinitions: ColumnProps<Alpha>[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "name",
|
|
|
|
|
|
title: "Alpha",
|
|
|
|
|
|
dataIndex: "name",
|
|
|
|
|
|
fixed: "left",
|
|
|
|
|
|
width: 190,
|
|
|
|
|
|
render: (_, row) => (
|
2026-09-07 23:02:55 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className="alpha-link"
|
2026-09-07 23:31:23 +08:00
|
|
|
|
title={`${row!.id} · ${row!.alpha_type ?? ""}`}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setDetailId(row!.id);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<span>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
{row!.research.favorite && <Tag size="small">收藏</Tag>}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{row!.name || row!.id}
|
|
|
|
|
|
</span>
|
2026-09-09 19:02:37 +08:00
|
|
|
|
{row!.name && <small className="muted">{row!.id}</small>}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</button>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "expression",
|
|
|
|
|
|
title: "表达式",
|
|
|
|
|
|
width: 260,
|
|
|
|
|
|
render: (_, row) => (
|
|
|
|
|
|
<code className="expression-cell" title={row!.expression_preview}>
|
|
|
|
|
|
{row!.expression_preview || "—"}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
2026-09-08 12:43:00 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "source",
|
|
|
|
|
|
title: "研究来源",
|
|
|
|
|
|
width: 150,
|
|
|
|
|
|
render: (_, row) =>
|
|
|
|
|
|
row!.source_kinds?.map(sourceLabel).join("、") || "暂无本地来源",
|
|
|
|
|
|
},
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "region",
|
|
|
|
|
|
title: "地区 / Universe",
|
|
|
|
|
|
width: 155,
|
|
|
|
|
|
render: (_, row) => (
|
|
|
|
|
|
<div className="stacked-cell">
|
|
|
|
|
|
<span>{row!.region ?? "—"}</span>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<span className="muted"> / {row!.universe ?? "—"}</span>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "status",
|
|
|
|
|
|
title: "平台状态",
|
|
|
|
|
|
width: 150,
|
|
|
|
|
|
render: (_, row) => (
|
|
|
|
|
|
<div className="stacked-cell">
|
|
|
|
|
|
<span>{row!.status ?? "未提供"}</span>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<span className="muted">
|
|
|
|
|
|
{" "}
|
|
|
|
|
|
· {row!.stage ?? "—"}
|
|
|
|
|
|
{row!.hidden ? " · 隐藏" : ""}
|
|
|
|
|
|
</span>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
...Object.entries(metricLabels).map(
|
|
|
|
|
|
([key, title]): ColumnProps<Alpha> => ({
|
|
|
|
|
|
key,
|
|
|
|
|
|
title,
|
2026-09-09 19:02:37 +08:00
|
|
|
|
width:
|
|
|
|
|
|
key in
|
|
|
|
|
|
{
|
|
|
|
|
|
sub_universe_sharpe: 1,
|
|
|
|
|
|
robust_universe_sharpe: 1,
|
|
|
|
|
|
two_year_sharpe: 1,
|
|
|
|
|
|
prod_correlation: 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
? 155
|
|
|
|
|
|
: 112,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
align: "right",
|
|
|
|
|
|
render: (_, row) => (
|
2026-09-10 15:31:09 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className="numeric"
|
|
|
|
|
|
style={{ color: metricColor(key, row![key as keyof Alpha]) }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatMetric(key, row![key as keyof Alpha])}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
}),
|
|
|
|
|
|
),
|
2026-09-09 19:02:37 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "check_type",
|
|
|
|
|
|
title: "检查结果",
|
|
|
|
|
|
width: 125,
|
|
|
|
|
|
render: (_, row) => (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
color={
|
|
|
|
|
|
row!.check_type?.startsWith("FAIL")
|
|
|
|
|
|
? "red"
|
|
|
|
|
|
: row!.check_type === "PASS"
|
|
|
|
|
|
? "green"
|
2026-09-12 22:34:14 +08:00
|
|
|
|
: row!.check_type === "PRE_CHECK"
|
|
|
|
|
|
? "blue"
|
|
|
|
|
|
: "grey"
|
2026-09-09 19:02:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{checkLabels[row!.check_type] || "待检查"}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "failed_checks",
|
|
|
|
|
|
title: "失败项",
|
|
|
|
|
|
width: 230,
|
|
|
|
|
|
render: (_, row) => (
|
2026-09-10 14:09:39 +08:00
|
|
|
|
<Typography.Text
|
|
|
|
|
|
className="alpha-failed-checks"
|
|
|
|
|
|
size="inherit"
|
|
|
|
|
|
style={{ width: "100%" }}
|
|
|
|
|
|
ellipsis={{
|
|
|
|
|
|
showTooltip: {
|
|
|
|
|
|
opts: {
|
|
|
|
|
|
content: row!.failed_checks?.join("\n"),
|
|
|
|
|
|
style: {
|
|
|
|
|
|
whiteSpace: "pre-line",
|
|
|
|
|
|
overflowWrap: "anywhere",
|
|
|
|
|
|
maxWidth: 420,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-09-09 19:02:37 +08:00
|
|
|
|
{row!.failed_checks?.join("、") || "—"}
|
2026-09-10 14:09:39 +08:00
|
|
|
|
</Typography.Text>
|
2026-09-09 19:02:37 +08:00
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: "neutralization",
|
|
|
|
|
|
title: "中性化",
|
|
|
|
|
|
width: 155,
|
|
|
|
|
|
render: (_, row) => row!.neutralization || "—",
|
|
|
|
|
|
},
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{
|
|
|
|
|
|
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",
|
2026-09-09 19:02:37 +08:00
|
|
|
|
title: "自定义标签",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
width: 180,
|
|
|
|
|
|
render: (_, row) => (
|
|
|
|
|
|
<div className="inline-tags">
|
|
|
|
|
|
{row!.research.tags.map((t) => (
|
|
|
|
|
|
<Tag key={t}>{t}</Tag>
|
|
|
|
|
|
))}
|
2026-09-09 19:02:37 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className="text-link"
|
|
|
|
|
|
aria-label={`编辑 ${row!.id} 的标签`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setTagEditor({
|
|
|
|
|
|
id: row!.id,
|
|
|
|
|
|
version: row!.research.version,
|
|
|
|
|
|
text: row!.research.tags.join(", "),
|
|
|
|
|
|
});
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
编辑标签
|
|
|
|
|
|
</button>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{ key: "language", title: "语言", width: 125, dataIndex: "language" },
|
2026-09-08 10:40:49 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "correlation",
|
|
|
|
|
|
title: "本地自相关",
|
|
|
|
|
|
width: 160,
|
|
|
|
|
|
render: (_, row) => {
|
|
|
|
|
|
const value = row!.local_correlation;
|
|
|
|
|
|
return value ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
title={`计算于 ${formatTime(value.calculated_at, account?.timezone)}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{value.stale ? "待重算" : correlationLabels[value.status]}
|
|
|
|
|
|
{value.max_correlation !== null &&
|
|
|
|
|
|
` · ${formatNumber(value.max_correlation, 3)}`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="muted">未检测</span>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-09-09 19:02:37 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "synced",
|
|
|
|
|
|
title: "同步时间",
|
|
|
|
|
|
width: 165,
|
|
|
|
|
|
render: (_, row) => formatTime(row!.synced_at, account?.timezone),
|
|
|
|
|
|
},
|
2026-09-08 10:40:49 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "submitted",
|
|
|
|
|
|
title: "提交时间",
|
|
|
|
|
|
width: 165,
|
|
|
|
|
|
render: (_, row) => formatTime(row!.date_submitted, account?.timezone),
|
|
|
|
|
|
},
|
2026-09-07 14:54:20 +08:00
|
|
|
|
{
|
|
|
|
|
|
key: "created",
|
|
|
|
|
|
title: "创建时间",
|
|
|
|
|
|
width: 165,
|
|
|
|
|
|
render: (_, row) => formatTime(row!.date_created, account?.timezone),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2026-09-12 20:16:02 +08:00
|
|
|
|
// Submitted alphas always expose the platform production correlation, including saved views.
|
|
|
|
|
|
const displayedColumns =
|
|
|
|
|
|
submission === "SUBMITTED"
|
|
|
|
|
|
? [...new Set([...visibleColumns, "prod_correlation"])]
|
|
|
|
|
|
: visibleColumns;
|
2026-09-07 23:31:23 +08:00
|
|
|
|
const columns = columnDefinitions
|
2026-09-12 20:16:02 +08:00
|
|
|
|
.filter((column) => displayedColumns.includes(String(column.key)))
|
2026-09-07 23:31:23 +08:00
|
|
|
|
.map((column) => (compact ? { ...column, fixed: undefined } : column));
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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";
|
2026-09-10 14:55:23 +08:00
|
|
|
|
const filterPanel = (
|
|
|
|
|
|
<form
|
|
|
|
|
|
className="alpha-filter-panel"
|
|
|
|
|
|
aria-label="Alpha 筛选条件"
|
|
|
|
|
|
onSubmit={(e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
applyFilters();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<header className="alpha-filter-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>筛选 Alpha</h2>
|
|
|
|
|
|
<p className="muted">设置搜索和筛选条件,查询后应用到当前视图。</p>
|
2026-09-09 08:34:34 +08:00
|
|
|
|
</div>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
icon={<IconClose aria-hidden="true" />}
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
aria-label="关闭筛选"
|
|
|
|
|
|
onClick={() => setFilterOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<div className="alpha-filter-body">
|
|
|
|
|
|
<div className="alpha-filter-fields">
|
|
|
|
|
|
<label className="alpha-filter-search">
|
|
|
|
|
|
搜索
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Input
|
2026-09-10 14:55:23 +08:00
|
|
|
|
prefix={<IconSearch aria-hidden="true" />}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
aria-label="搜索 Alpha"
|
|
|
|
|
|
placeholder="搜索 ID、名称或表达式"
|
|
|
|
|
|
value={draft.q ?? ""}
|
|
|
|
|
|
onChange={(v) => updateDraft("q", v)}
|
|
|
|
|
|
showClear
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
地区
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="地区"
|
|
|
|
|
|
placeholder="全部地区"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.region || undefined}
|
|
|
|
|
|
optionList={options("region")}
|
|
|
|
|
|
onChange={(v) => updateDraft("region", v)}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
平台状态
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="平台状态"
|
|
|
|
|
|
placeholder="全部平台状态"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.status || undefined}
|
|
|
|
|
|
optionList={options("status")}
|
|
|
|
|
|
onChange={(v) => updateDraft("status", v)}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
研究状态
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="研究状态筛选"
|
|
|
|
|
|
placeholder="全部研究状态"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.research_state || undefined}
|
|
|
|
|
|
optionList={stateOptions}
|
|
|
|
|
|
onChange={(v) => updateDraft("research_state", v)}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
检查结果
|
2026-09-09 19:02:37 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="检查结果筛选"
|
|
|
|
|
|
placeholder="全部检查结果"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.check_type || undefined}
|
|
|
|
|
|
optionList={Object.entries(checkLabels).map(([value, label]) => ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
label,
|
|
|
|
|
|
}))}
|
|
|
|
|
|
onChange={(v) => updateDraft("check_type", v)}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
自定义标签
|
2026-09-09 19:02:37 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="自定义标签筛选"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
filter
|
|
|
|
|
|
value={draft.tag || undefined}
|
|
|
|
|
|
optionList={options("tags")}
|
|
|
|
|
|
placeholder="全部标签"
|
|
|
|
|
|
onChange={(v) => updateDraft("tag", v)}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<details className="advanced-filters">
|
|
|
|
|
|
<summary>更多筛选</summary>
|
|
|
|
|
|
<div className="alpha-filter-fields">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
研究来源
|
|
|
|
|
|
<Select
|
|
|
|
|
|
aria-label="研究来源筛选"
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.source || undefined}
|
|
|
|
|
|
optionList={options("source").map((option) => ({
|
|
|
|
|
|
...option,
|
|
|
|
|
|
label: sourceLabel(option.value),
|
|
|
|
|
|
}))}
|
|
|
|
|
|
placeholder="全部来源"
|
|
|
|
|
|
onChange={(v) => updateDraft("source", v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
研究编号
|
|
|
|
|
|
<Input
|
|
|
|
|
|
aria-label="研究编号筛选"
|
|
|
|
|
|
value={draft.research_id || ""}
|
|
|
|
|
|
onChange={(v) => updateDraft("research_id", v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
{[
|
|
|
|
|
|
["universe", "Universe"],
|
|
|
|
|
|
["alpha_type", "Alpha 类型"],
|
|
|
|
|
|
["language", "语言"],
|
|
|
|
|
|
["stage", "IS / OS"],
|
|
|
|
|
|
].map(([key, label]) => (
|
|
|
|
|
|
<label key={key}>
|
|
|
|
|
|
{label}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Select
|
|
|
|
|
|
showClear
|
2026-09-10 14:55:23 +08:00
|
|
|
|
aria-label={label}
|
|
|
|
|
|
value={draft[key] || undefined}
|
|
|
|
|
|
optionList={options(key)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
placeholder="全部"
|
2026-09-10 14:55:23 +08:00
|
|
|
|
onChange={(v) => updateDraft(key, v)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
))}
|
|
|
|
|
|
<label>
|
|
|
|
|
|
平台隐藏状态
|
|
|
|
|
|
<Select
|
|
|
|
|
|
showClear
|
|
|
|
|
|
value={draft.hidden || undefined}
|
|
|
|
|
|
optionList={[
|
|
|
|
|
|
{ value: "false", label: "可见" },
|
|
|
|
|
|
{ value: "true", label: "隐藏" },
|
|
|
|
|
|
]}
|
|
|
|
|
|
placeholder="全部"
|
|
|
|
|
|
onChange={(v) => updateDraft("hidden", v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
中性化
|
|
|
|
|
|
<Input
|
|
|
|
|
|
aria-label="中性化筛选"
|
|
|
|
|
|
value={draft.neutralization || ""}
|
|
|
|
|
|
placeholder="例如 INDUSTRY"
|
|
|
|
|
|
onChange={(v) => updateDraft("neutralization", v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
创建时间起(UTC)
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
value={draft.created_from?.slice(0, 10) || ""}
|
|
|
|
|
|
onChange={(v) =>
|
|
|
|
|
|
updateDraft("created_from", v ? `${v}T00:00:00Z` : "")
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}
|
2026-09-10 14:55:23 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
2026-09-09 09:29:30 +08:00
|
|
|
|
</div>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<p className="muted">
|
|
|
|
|
|
数值筛选使用平台原始值,例如 Turnover 0.15 表示 15%。
|
|
|
|
|
|
检查结果仅汇总已同步的平台检查;预检通过不代表可以提交,活动资格可用自定义标签记录。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</details>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<footer className="alpha-filter-footer">
|
|
|
|
|
|
<Button type="tertiary" theme="borderless" onClick={clearFilters}>
|
|
|
|
|
|
重置
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button htmlType="submit" theme="solid">
|
|
|
|
|
|
查询
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{!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" ref={tablePanel}>
|
2026-09-09 09:29:30 +08:00
|
|
|
|
<SavedViews
|
2026-09-10 14:55:23 +08:00
|
|
|
|
submission={submission}
|
|
|
|
|
|
selectedId={selectedViewId}
|
|
|
|
|
|
onSelect={setSelectedViewId}
|
|
|
|
|
|
onSubmissionChange={changeSubmission}
|
|
|
|
|
|
suspended={overlaySuspended || !active}
|
|
|
|
|
|
onOverlay={onOverlay}
|
2026-09-09 09:29:30 +08:00
|
|
|
|
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
|
2026-09-12 20:16:02 +08:00
|
|
|
|
columns={displayedColumns}
|
2026-09-09 09:29:30 +08:00
|
|
|
|
onRestore={(view) => {
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setFilterOpen(false);
|
2026-09-09 09:29:30 +08:00
|
|
|
|
const {
|
|
|
|
|
|
submission: savedSubmission,
|
|
|
|
|
|
sort: savedSort,
|
|
|
|
|
|
direction: savedDirection,
|
|
|
|
|
|
limit,
|
|
|
|
|
|
offset: _offset,
|
|
|
|
|
|
...rest
|
|
|
|
|
|
} = view.filters;
|
|
|
|
|
|
const values = Object.fromEntries(
|
|
|
|
|
|
Object.entries(rest)
|
|
|
|
|
|
.filter(([, v]) => v !== null && v !== undefined)
|
|
|
|
|
|
.map(([k, v]) => [k, String(v)]),
|
|
|
|
|
|
);
|
|
|
|
|
|
setFilters(values);
|
|
|
|
|
|
setDraft(values);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
setSelected([]);
|
2026-09-10 14:55:23 +08:00
|
|
|
|
setSubmission(
|
|
|
|
|
|
savedSubmission === "SUBMITTED" ? "SUBMITTED" : "UNSUBMITTED",
|
|
|
|
|
|
);
|
2026-09-09 09:29:30 +08:00
|
|
|
|
setSort(String(savedSort || "date_created"));
|
|
|
|
|
|
setDirection(String(savedDirection || "desc"));
|
|
|
|
|
|
if (typeof limit === "number") setPageSize(limit);
|
|
|
|
|
|
const next = [
|
|
|
|
|
|
...new Set([
|
|
|
|
|
|
"name",
|
|
|
|
|
|
...view.columns.filter((c) => c in columnLabels),
|
|
|
|
|
|
]),
|
|
|
|
|
|
];
|
|
|
|
|
|
setVisibleColumns(next);
|
|
|
|
|
|
localStorage.setItem("alpha-columns", JSON.stringify(next));
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className="table-toolbar alpha-toolbar"
|
|
|
|
|
|
aria-label="Alpha 表格工具栏"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="inline-actions alpha-view-tools">
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="custom"
|
|
|
|
|
|
position="bottomLeft"
|
|
|
|
|
|
visible={filterOpen && active && !overlaySuspended}
|
|
|
|
|
|
onClickOutSide={() => setFilterOpen(false)}
|
|
|
|
|
|
onEscKeyDown={() => setFilterOpen(false)}
|
|
|
|
|
|
content={filterPanel}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<IconFilter aria-hidden="true" />}
|
|
|
|
|
|
type={filterOpen || activeFilterCount ? "primary" : "tertiary"}
|
|
|
|
|
|
theme={filterOpen || activeFilterCount ? "light" : "borderless"}
|
|
|
|
|
|
aria-label="筛选 Alpha"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setFilterOpen((open) => !open);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
筛选
|
|
|
|
|
|
{activeFilterCount > 0 && (
|
|
|
|
|
|
<span className="alpha-filter-count">
|
|
|
|
|
|
{activeFilterCount}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
position="bottomRight"
|
|
|
|
|
|
content={
|
|
|
|
|
|
<div className="columns-picker">
|
|
|
|
|
|
<strong>显示列</strong>
|
|
|
|
|
|
<CheckboxGroup
|
|
|
|
|
|
direction="vertical"
|
2026-09-12 20:16:02 +08:00
|
|
|
|
value={displayedColumns}
|
2026-09-10 14:55:23 +08:00
|
|
|
|
options={Object.entries(columnLabels).map(
|
|
|
|
|
|
([value, label]) => ({
|
|
|
|
|
|
value,
|
|
|
|
|
|
label,
|
2026-09-12 20:16:02 +08:00
|
|
|
|
disabled:
|
|
|
|
|
|
value === "name" ||
|
|
|
|
|
|
(submission === "SUBMITTED" &&
|
|
|
|
|
|
value === "prod_correlation"),
|
2026-09-10 14:55:23 +08:00
|
|
|
|
}),
|
|
|
|
|
|
)}
|
|
|
|
|
|
onChange={(values) => {
|
|
|
|
|
|
const next = values.map(String);
|
|
|
|
|
|
setVisibleColumns(next);
|
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
|
"alpha-columns",
|
|
|
|
|
|
JSON.stringify(next),
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<IconSetting aria-hidden="true" />}
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
aria-label="显示列设置"
|
|
|
|
|
|
>
|
|
|
|
|
|
列设置
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
<Popover
|
|
|
|
|
|
trigger="click"
|
|
|
|
|
|
position="bottomLeft"
|
|
|
|
|
|
content={
|
|
|
|
|
|
<div className="alpha-sort-panel">
|
|
|
|
|
|
<h2>排序</h2>
|
|
|
|
|
|
<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);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<IconSort aria-hidden="true" />}
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
aria-label="排序设置"
|
|
|
|
|
|
>
|
|
|
|
|
|
排序
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Popover>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<div className="inline-actions alpha-table-actions">
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<a href={`/api/v1/alphas/export?${query}`}>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
icon={<IconDownload aria-hidden="true" />}
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
导出
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</a>
|
2026-09-09 08:34:34 +08:00
|
|
|
|
<Button
|
2026-09-10 14:55:23 +08:00
|
|
|
|
icon={<IconUpload aria-hidden="true" />}
|
|
|
|
|
|
theme="borderless"
|
2026-09-09 08:34:34 +08:00
|
|
|
|
type="tertiary"
|
2026-09-10 14:55:23 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setImporting(true);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
2026-09-09 08:34:34 +08:00
|
|
|
|
>
|
|
|
|
|
|
导入 Alpha ID
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-09-10 14:55:23 +08:00
|
|
|
|
icon={<IconRefresh aria-hidden="true" />}
|
2026-09-09 08:34:34 +08:00
|
|
|
|
theme="solid"
|
|
|
|
|
|
disabled={!connected}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSyncingScope(submission);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
按天同步
|
|
|
|
|
|
</Button>
|
2026-09-09 20:10:20 +08:00
|
|
|
|
{submission === "SUBMITTED" && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
loading={busy === "pnl_backfill"}
|
|
|
|
|
|
disabled={!connected || Boolean(busy)}
|
|
|
|
|
|
title="补取全部本地已提交 Alpha 缺失的 PnL,不受筛选和分页影响"
|
|
|
|
|
|
onClick={() => void newTask("pnl_backfill")}
|
|
|
|
|
|
>
|
|
|
|
|
|
检查pnl
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-09-09 08:34:34 +08:00
|
|
|
|
{submission === "SUBMITTED" && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
loading={busy === "full_sync"}
|
|
|
|
|
|
disabled={!connected}
|
|
|
|
|
|
onClick={() => void newTask("full_sync")}
|
|
|
|
|
|
>
|
|
|
|
|
|
全量同步已提交
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-10 14:55:23 +08:00
|
|
|
|
{selected.length > 0 && (
|
|
|
|
|
|
<div className="alpha-selection-actions inline-actions">
|
|
|
|
|
|
<span className="selection-text">已选择 {selected.length} 条</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
loading={busy === "self_correlation"}
|
|
|
|
|
|
onClick={() => void newTask("self_correlation", selected)}
|
|
|
|
|
|
>
|
|
|
|
|
|
检测自相关
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setBulkVersions(
|
|
|
|
|
|
Object.fromEntries(
|
|
|
|
|
|
data.items
|
|
|
|
|
|
.filter((row) => selected.includes(row.id))
|
|
|
|
|
|
.map((row) => [row.id, row.research.version]),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
setBulkOpen(true);
|
|
|
|
|
|
onOverlay();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
批量编辑
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
disabled={!connected}
|
|
|
|
|
|
loading={busy === "alpha_refresh"}
|
|
|
|
|
|
onClick={() => void newTask("alpha_refresh", selected)}
|
|
|
|
|
|
>
|
|
|
|
|
|
刷新选中
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
theme="borderless"
|
|
|
|
|
|
type="tertiary"
|
|
|
|
|
|
onClick={() => setSelected([])}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消选择
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Table<Alpha>
|
|
|
|
|
|
columns={columns}
|
|
|
|
|
|
dataSource={data.items}
|
|
|
|
|
|
rowKey="id"
|
|
|
|
|
|
loading={loading}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
className="alpha-table"
|
|
|
|
|
|
size="small"
|
2026-09-07 14:54:20 +08:00
|
|
|
|
scroll={{
|
2026-09-07 23:31:23 +08:00
|
|
|
|
y: "100%",
|
2026-09-07 14:54:20 +08:00
|
|
|
|
x: columns.reduce((sum, c) => sum + Number(c.width || 120), 0) + 60,
|
|
|
|
|
|
}}
|
|
|
|
|
|
rowSelection={{
|
|
|
|
|
|
selectedRowKeys: selected,
|
|
|
|
|
|
onChange: (keys) => setSelected((keys ?? []).map(String)),
|
2026-09-07 23:31:23 +08:00
|
|
|
|
fixed: !compact,
|
2026-09-07 14:54:20 +08:00
|
|
|
|
}}
|
2026-09-07 23:31:23 +08:00
|
|
|
|
pagination={false}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
empty={
|
|
|
|
|
|
<Empty
|
|
|
|
|
|
className="alpha-empty"
|
|
|
|
|
|
title={
|
|
|
|
|
|
activeFilterCount
|
|
|
|
|
|
? "没有符合条件的 Alpha"
|
|
|
|
|
|
: "从第一个 Alpha 开始"
|
|
|
|
|
|
}
|
|
|
|
|
|
description={
|
|
|
|
|
|
activeFilterCount
|
|
|
|
|
|
? "调整筛选条件,探索更多信号。"
|
|
|
|
|
|
: "连接 WorldQuant 并同步数据,或通过 Alpha ID 导入已有研究。"
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<footer className="table-pagination" aria-label="Alpha 分页">
|
2026-09-10 14:55:23 +08:00
|
|
|
|
<div className="alpha-table-meta">
|
|
|
|
|
|
<span className="muted">
|
|
|
|
|
|
{submission === "UNSUBMITTED" ? "待提交" : "已提交"} Alpha{" "}
|
|
|
|
|
|
<span className="count-pill">{data.total.toLocaleString()}</span>{" "}
|
|
|
|
|
|
条
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="alpha-last-sync">
|
|
|
|
|
|
最近数据更新{" "}
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{formatTime(
|
|
|
|
|
|
typeof facets.last_sync === "string"
|
|
|
|
|
|
? facets.last_sync
|
|
|
|
|
|
: null,
|
|
|
|
|
|
account?.timezone,
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-09-07 23:31:23 +08:00
|
|
|
|
<Pagination
|
|
|
|
|
|
size={compact ? "small" : "default"}
|
|
|
|
|
|
currentPage={page}
|
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
|
total={data.total}
|
|
|
|
|
|
showSizeChanger
|
|
|
|
|
|
pageSizeOpts={[25, 50, 100]}
|
|
|
|
|
|
preventPageChangeOnPageSizeChange
|
|
|
|
|
|
onPageChange={setPage}
|
|
|
|
|
|
onPageSizeChange={(size) => {
|
|
|
|
|
|
setPageSize(size);
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</footer>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
</section>
|
2026-09-09 19:02:37 +08:00
|
|
|
|
<Modal
|
|
|
|
|
|
title={`编辑自定义标签 · ${tagEditor?.id || ""}`}
|
|
|
|
|
|
visible={Boolean(tagEditor) && !overlaySuspended}
|
|
|
|
|
|
onCancel={() => setTagEditor(null)}
|
|
|
|
|
|
confirmLoading={busy === "tags"}
|
|
|
|
|
|
okText="保存标签"
|
|
|
|
|
|
onOk={() => void saveTags()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p>
|
|
|
|
|
|
用逗号分隔多个标签,例如
|
|
|
|
|
|
PPAC、待活动提交。标签保存在本地,平台同步不会覆盖。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
aria-label="自定义标签"
|
|
|
|
|
|
value={tagEditor?.text || ""}
|
|
|
|
|
|
placeholder="例如 PPAC, 待活动提交"
|
|
|
|
|
|
onChange={(text) =>
|
|
|
|
|
|
setTagEditor((current) =>
|
|
|
|
|
|
current ? { ...current, text } : current,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Modal>
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<Modal
|
|
|
|
|
|
title="导入 Alpha ID"
|
2026-09-07 23:02:55 +08:00
|
|
|
|
visible={importing && !overlaySuspended}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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
|
2026-09-07 23:02:55 +08:00
|
|
|
|
title={`批量编辑 ${Object.keys(bulkVersions).length} 条研究记录`}
|
|
|
|
|
|
visible={bulkOpen && !overlaySuspended}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
onCancel={() => setBulkOpen(false)}
|
|
|
|
|
|
confirmLoading={busy === "bulk"}
|
|
|
|
|
|
onOk={() => void bulkSave()}
|
|
|
|
|
|
okText="保存修改"
|
|
|
|
|
|
>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
添加标签
|
|
|
|
|
|
<Input
|
|
|
|
|
|
aria-label="批量添加标签"
|
2026-09-09 19:02:37 +08:00
|
|
|
|
placeholder="用逗号分隔,例如 PPAC, 待活动提交"
|
2026-09-07 14:54:20 +08:00
|
|
|
|
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>
|
2026-09-08 10:40:49 +08:00
|
|
|
|
{syncingScope && (
|
|
|
|
|
|
<AlphaSyncDialog
|
|
|
|
|
|
submission={syncingScope}
|
|
|
|
|
|
suspended={overlaySuspended}
|
|
|
|
|
|
onClose={() => setSyncingScope(null)}
|
|
|
|
|
|
onTask={onTask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
<AlphaDetail
|
2026-09-08 12:43:00 +08:00
|
|
|
|
onAction={onAction}
|
2026-09-07 23:02:55 +08:00
|
|
|
|
suspended={overlaySuspended}
|
|
|
|
|
|
chatOffset={chatOffset}
|
2026-09-07 14:54:20 +08:00
|
|
|
|
taskPanelOpen={taskPanelOpen}
|
|
|
|
|
|
id={detailId}
|
|
|
|
|
|
version={`${version}:${localVersion}`}
|
|
|
|
|
|
timezone={account?.timezone}
|
|
|
|
|
|
onClose={() => setDetailId(null)}
|
|
|
|
|
|
onSaved={refresh}
|
|
|
|
|
|
onTask={onTask}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|