feat(web): 实验对比页支持批量删除 / 发起时间列 / 详情图层,打开归档改新页面
用户要求的三件事(原话):①批量删除;②增加「测试发起时间」;③点击详情用图层展示、
打开归档用新页面展示。
- **批量删除**(`POST /experiments/bulk-delete`,已在上一个提交实现接口):
表头全选(只作用本页,工具条写明范围)+ 每行删除复选框,工具条显示「已选 N 个(待删除)
| 删除所选 | 取消选择」;确认框写清**不可恢复**、结果只存归档这一份、关联作业记录会保留但
读不回结果、想留底先导出 JSON。单次接口上限 200 个 id,超过前端分批;**分批中途失败时
如实报「已删除 X 个,之后失败 —— 原文」**(前面几批是真删了,不能报成一个都没删);
接口返回的 `missing` 单独用警示色报「M 个不存在(未计入删除数)」,不把「不存在」当删成功;
只清掉确实删掉/本来不存在的选中项(没删成的留在选中里可直接重试),并同步清掉已删归档
的详情图层 / 对比选择 / 勾选状态。
- **发起时间列**:`created_at` 用 `new Date()` 转**本地时间** `YYYY-MM-DD HH:mm:ss`
(不直接截 UTC 字符串),空值显示「—」;表头可排序(空值恒排末尾、同秒用 id 兜底)。
- **详情图层**:新增通用 `components/Modal.tsx`(portal / `role="dialog" aria-modal` /
`aria-labelledby` / 锁 body 滚动 / ESC 关闭 / 遮罩 mousedown 关闭而点内容区不关 /
打开聚焦、关闭把焦点还给触发按钮 / Tab 焦点陷阱;样式走 CSS Module,不动 globals.css)。
图层内复用 `ArchiveResultView`(按 kind 分发,回测的净值曲线、成交明细、买卖说明、
因子曲线与「新页面放大」链接都在图层里可用),元信息区列出 id/类型/发起时间/版本/作业/区间/体积;
标题栏含「在新页面打开完整归档」。
- **打开归档**:列表入口改 `target="_blank" rel="noreferrer"`(URL 不变)。
验证(真实浏览器 CDP,读数原文已记录):
- 表头 `发起时间 ↓`,首行 `EXP-8EA2819B / 2026-10-01 17:45:05`;点一次变升序(首行
2026-09-06 17:17:37),再点回降序。
- 图层:`{aria-modal:true, 标题「EXP-8EA2819B 归档详情 · 回测」, canvas:35, tables:10,
body.overflow:hidden}`;点内容区不关、遮罩关闭后焦点回到「详情」、ESC 关闭且恢复滚动。
- 批量删除:造 2 条一次性归档 → 勾选 → 确认「将永久删除 2 个归档」→ 横幅「已删除 2 个归档。」,
行数 59→56;用 `/api/experiments` 逐条集合核对 `before-after=[]`、`after-before=[]`
(只删勾选的);`missing` 分支实测「已删除 0 个归档,1 个不存在(未计入删除数)。」;
进行中态实测「删除中… + disabled」。造出的 4 条自检归档已全部清理(404,关键词命中 0)。
- 打开归档用真实鼠标事件点击:页面目标 `/experiments` → `/experiments/EXP-10581FED`
(合成 `a.click()` 会被 Chrome 弹窗策略拦掉,故用真实鼠标事件验证)。
- 另外把图层关闭按钮调大调亮(34×34、`--text-1`、图标 16px):2 倍放大截图逐字核对,
原先「×」细到几乎看不见,现在与「在新页面打开完整归档」同高同线、清晰可见。
This commit is contained in:
@@ -11,8 +11,15 @@
|
|||||||
* 微调时一眼看出「这次到底改了什么」。
|
* 微调时一眼看出「这次到底改了什么」。
|
||||||
*
|
*
|
||||||
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
|
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
|
||||||
|
*
|
||||||
|
* 后续补上的三件事(都是「列表页该有的基本操作」):
|
||||||
|
* - **批量删除**:每行第一列的复选框(与「对比」那列刻意分开)攒出待删集合,
|
||||||
|
* 顶部工具条一次提交;后端按 id 逐个回报 `deleted` / `missing`,界面**照实**显示。
|
||||||
|
* - **测试发起时间列**:用 `created_at` 转本地时区渲染,并可点击表头排序。
|
||||||
|
* - **详情改成图层**:列表原地看结果,不再把用户从列表页带走;「打开归档」仍在
|
||||||
|
* 新标签页打开完整视图(需要 URL 可分享/可刷新时用它)。
|
||||||
*/
|
*/
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
|
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
|
||||||
@@ -26,13 +33,14 @@ import {
|
|||||||
Btn,
|
Btn,
|
||||||
Banner,
|
Banner,
|
||||||
Empty,
|
Empty,
|
||||||
Metric,
|
|
||||||
SkeletonLines,
|
SkeletonLines,
|
||||||
Loading,
|
Loading,
|
||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
|
||||||
import { CHART, seriesColor } from "@/components/charts/theme";
|
import { seriesColor } from "@/components/charts/theme";
|
||||||
|
import { Modal } from "@/components/Modal";
|
||||||
|
import { ArchiveResultView } from "@/components/ArchiveResultView";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
|
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
|
||||||
@@ -94,6 +102,40 @@ function shortDataVersion(v: string): string {
|
|||||||
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
|
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试发起时间:后端给的是 ISO 字符串(本机时区的 naive datetime,如 `2026-10-01T17:45:05`)。
|
||||||
|
*
|
||||||
|
* 必须过一遍 `new Date(...)` 再取本地字段,**不能直接截字符串**:截字符串等于把后端
|
||||||
|
* 字符串里的时间当本地时间照搬,一旦后端改成带时区的 UTC(`...Z`)就会差 8 小时,
|
||||||
|
* 而且错得没有任何提示。由 Date 负责时区换算,格式异常时原样回显(不假装是时间)。
|
||||||
|
*/
|
||||||
|
function fmtLocalTime(v?: string | null): string {
|
||||||
|
if (!v) return "—";
|
||||||
|
const d = new Date(v);
|
||||||
|
if (Number.isNaN(d.getTime())) return v;
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
|
||||||
|
d.getMinutes()
|
||||||
|
)}:${p(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端批量删除单次最多接受的 id 数(`BULK_DELETE_MAX_IDS`);超了接口直接 422,一个都不删 */
|
||||||
|
const BULK_DELETE_MAX_IDS = 200;
|
||||||
|
|
||||||
|
/** 按上限切片:超过 200 个选中必须分批提交,否则整批被 422 拒掉 */
|
||||||
|
function chunkIds(ids: string[], size: number): string[][] {
|
||||||
|
const out: string[][] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除的响应(后端语义:存在的进 deleted,库里没有的如实进 missing,绝不静默吞掉) */
|
||||||
|
interface BulkDeleteResult {
|
||||||
|
deleted: string[];
|
||||||
|
missing: string[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
|
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
|
||||||
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||||
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
|
||||||
@@ -124,9 +166,19 @@ function ExperimentsInner() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState("");
|
||||||
|
/** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
/** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */
|
||||||
|
const [detailTrigger, setDetailTrigger] = useState<HTMLElement | null>(null);
|
||||||
const [jobId, setJobId] = useState("");
|
const [jobId, setJobId] = useState("");
|
||||||
const [jobStatus, setJobStatus] = useState("");
|
const [jobStatus, setJobStatus] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
/**
|
||||||
|
* 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。
|
||||||
|
* tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。
|
||||||
|
*/
|
||||||
|
const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null);
|
||||||
|
|
||||||
// 对比状态
|
// 对比状态
|
||||||
const [picked, setPicked] = useState<string[]>([]);
|
const [picked, setPicked] = useState<string[]>([]);
|
||||||
@@ -134,6 +186,16 @@ function ExperimentsInner() {
|
|||||||
const [comparing, setComparing] = useState(false);
|
const [comparing, setComparing] = useState(false);
|
||||||
const [onlyDiff, setOnlyDiff] = useState(true);
|
const [onlyDiff, setOnlyDiff] = useState(true);
|
||||||
|
|
||||||
|
// 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**——
|
||||||
|
// 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作,
|
||||||
|
// 不该和「我想比一比」共用一次勾选(误删代价太大)。
|
||||||
|
const [toDelete, setToDelete] = useState<string[]>([]);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// 列表排序:默认按发起时间倒序(与后端 list_filtered 的 created_at desc 一致),
|
||||||
|
// 点表头在升降之间切换。
|
||||||
|
const [timeSort, setTimeSort] = useState<"desc" | "asc">("desc");
|
||||||
|
|
||||||
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
|
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
|
||||||
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
|
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
|
||||||
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
|
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
|
||||||
@@ -186,13 +248,113 @@ function ExperimentsInner() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function open(id: string) {
|
/**
|
||||||
setDetailLoading(true);
|
* 详情加载序号:用户可能连点两行的「详情」,先发的请求后到会把 A 的内容
|
||||||
|
* 盖到 B 的图层上(旧代码原地渲染时同样有这个竞态)。每次打开自增,回调里
|
||||||
|
* 只认最后一次,迟到的一律丢弃。
|
||||||
|
*/
|
||||||
|
const detailSeq = useRef(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开详情图层:失败**原文**显示在后端错误里(不吞成"加载失败"),
|
||||||
|
* 因为这里通常能直接看出是 404(已删)/ 500 还是网络问题。
|
||||||
|
*/
|
||||||
|
function openDetail(id: string, trigger: HTMLElement | null) {
|
||||||
|
const seq = ++detailSeq.current;
|
||||||
|
setDetailTrigger(trigger);
|
||||||
|
setDetailId(id);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
apiGet<ExperimentDetail>(`/experiments/${id}`)
|
setDetailError("");
|
||||||
.then((d) => setDetail(d))
|
setDetailLoading(true);
|
||||||
.catch((e: Error) => setError(e.message))
|
apiGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`)
|
||||||
.finally(() => setDetailLoading(false));
|
.then((d) => {
|
||||||
|
if (seq === detailSeq.current) setDetail(d);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (seq === detailSeq.current) setDetailError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (seq === detailSeq.current) setDetailLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
// 自增序号:关闭之后迟到的响应不能再把图层"顶"回来
|
||||||
|
detailSeq.current += 1;
|
||||||
|
setDetailId(null);
|
||||||
|
setDetail(null);
|
||||||
|
setDetailError("");
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单行「删除选择」勾选:不设上限——批量删除本来就要跨多行选 */
|
||||||
|
function toggleDelete(id: string) {
|
||||||
|
setNotice(null);
|
||||||
|
setToDelete((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除:一次最多 200 个 id,超了前端分批,最后把各批的 deleted/missing 合并上报 */
|
||||||
|
async function removeSelected() {
|
||||||
|
if (!toDelete.length || deleting) return;
|
||||||
|
const ids = [...toDelete];
|
||||||
|
const n = ids.length;
|
||||||
|
const ok = window.confirm(
|
||||||
|
`将永久删除 ${n} 个归档,不可恢复。\n\n` +
|
||||||
|
`· 结果只存归档这一份:删除后这些回测/因子测试/选股的净值曲线、成交与 spec 都无法再查看,也无法导出;\n` +
|
||||||
|
`· 关联的作业记录会保留,但结果读不回(接口会如实标注"结果已随归档删除");\n` +
|
||||||
|
`· 想留底请先用每行「打开归档」进详情页导出完整 JSON。\n\n确认删除这 ${n} 个归档?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
setError("");
|
||||||
|
setNotice(null);
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const missing: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const batch of chunkIds(ids, BULK_DELETE_MAX_IDS)) {
|
||||||
|
const r = await apiPost<BulkDeleteResult>("/experiments/bulk-delete", { ids: batch });
|
||||||
|
deleted.push(...r.deleted);
|
||||||
|
missing.push(...r.missing);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 分批提交时前面几批**已经真删了**:必须把"已删几个"说出来,
|
||||||
|
// 否则用户会以为一个都没删(或以为全删了)——两种都是谎报。
|
||||||
|
setError(`批量删除未全部完成:已删除 ${deleted.length} 个,之后失败 —— ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
// 只清掉「确实删掉了」与「本来就不存在」的选中项;因网络/接口失败没删成的仍留在
|
||||||
|
// 选中里,用户可直接重试,不用重新勾一遍。
|
||||||
|
const gone = new Set([...deleted, ...missing]);
|
||||||
|
setToDelete((prev) => prev.filter((id) => !gone.has(id)));
|
||||||
|
// 正开着的详情/对比里若含已删归档,必须一起清掉,否则会继续展示一份已不存在的快照。
|
||||||
|
// 用 detailId 而不是 detail 判定:详情可能还在加载(detail 还是 null),
|
||||||
|
// 这时请求回来仍会把一份已删的快照渲染进图层。
|
||||||
|
if (detailId && gone.has(detailId)) closeDetail();
|
||||||
|
setCompare((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const left = prev.filter((r) => !gone.has(r.id));
|
||||||
|
if (left.length === prev.length) return prev;
|
||||||
|
// 剩不到 2 条就没有"对比"可言了:关掉对比区,而不是画一条什么都比不出来的曲线
|
||||||
|
return left.length >= 2 ? left : null;
|
||||||
|
});
|
||||||
|
setPicked((prev) => prev.filter((id) => !gone.has(id)));
|
||||||
|
|
||||||
|
if (deleted.length || missing.length) {
|
||||||
|
// 照实报告:missing 非空必须显示("不存在的 id 也算删成功"会让用户以为库里干净了)
|
||||||
|
setNotice({
|
||||||
|
text:
|
||||||
|
`已删除 ${deleted.length} 个归档` +
|
||||||
|
(missing.length
|
||||||
|
? `,${missing.length} 个不存在(可能已被别处删掉,未计入删除数)`
|
||||||
|
: "") +
|
||||||
|
"。",
|
||||||
|
// missing 非空用警示色:不能让"有 id 没找到"被当成完全成功一眼扫过去
|
||||||
|
tone: missing.length ? "warn" : "info",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rerun(exp: ExperimentMeta) {
|
function rerun(exp: ExperimentMeta) {
|
||||||
@@ -218,7 +380,8 @@ function ExperimentsInner() {
|
|||||||
if (j.status === "success" || j.status === "failed" || tries > 60) {
|
if (j.status === "success" || j.status === "failed" || tries > 60) {
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
load();
|
load();
|
||||||
setDetail(null);
|
// 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串
|
||||||
|
closeDetail();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => window.clearInterval(timer));
|
.catch(() => window.clearInterval(timer));
|
||||||
@@ -258,7 +421,47 @@ function ExperimentsInner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
|
/**
|
||||||
|
* 展示用的行序:按「测试发起时间」升/降序排列(表头可切换)。
|
||||||
|
*
|
||||||
|
* 两个细节:
|
||||||
|
* - `created_at` 缺失的归档一律排在**末尾**,不参与升降 —— 让「—」混在时间中间会
|
||||||
|
* 让人以为那是一条很新/很旧的归档,那是假信息;
|
||||||
|
* - 同秒(MySQL datetime(0) 只有秒精度)用 id 兜底,方向与时间一致,
|
||||||
|
* 否则等长时间的行序会随机抖动(也和后端 created_at desc, id desc 的口径一致)。
|
||||||
|
*/
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const ts = (e: ExperimentMeta) => (e.created_at ? new Date(e.created_at).getTime() : NaN);
|
||||||
|
const byId = (a: ExperimentMeta, b: ExperimentMeta) =>
|
||||||
|
timeSort === "desc" ? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||||
|
return [...exps].sort((a, b) => {
|
||||||
|
const ta = ts(a);
|
||||||
|
const tb = ts(b);
|
||||||
|
const aBad = Number.isNaN(ta);
|
||||||
|
const bBad = Number.isNaN(tb);
|
||||||
|
if (aBad && bBad) return byId(a, b);
|
||||||
|
if (aBad) return 1;
|
||||||
|
if (bBad) return -1;
|
||||||
|
if (ta !== tb) return timeSort === "desc" ? tb - ta : ta - tb;
|
||||||
|
return byId(a, b);
|
||||||
|
});
|
||||||
|
}, [exps, timeSort]);
|
||||||
|
|
||||||
|
const pageIds = rows.map((e) => e.id);
|
||||||
|
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => toDelete.includes(id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表头「全选本页」:只作用于**当前显示**的这些行。
|
||||||
|
* 列表可能有后端单页上限(total > rows.length),"全选筛选结果"会把看不见的行也删掉,
|
||||||
|
* 那是拿用户没看过的数据冒险,所以这里不做,并在工具条里说明范围。
|
||||||
|
*/
|
||||||
|
function toggleAllOnPage() {
|
||||||
|
setNotice(null);
|
||||||
|
setToDelete((prev) => {
|
||||||
|
if (allOnPageSelected) return prev.filter((id) => !pageIds.includes(id));
|
||||||
|
return Array.from(new Set([...prev, ...pageIds]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -276,19 +479,56 @@ function ExperimentsInner() {
|
|||||||
|
|
||||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||||
|
|
||||||
{picked.length > 0 ? (
|
{/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */}
|
||||||
|
{notice ? <Banner tone={notice.tone}>{notice.text}</Banner> : null}
|
||||||
|
|
||||||
|
{picked.length > 0 || toDelete.length > 0 ? (
|
||||||
<div className="sticky-bar">
|
<div className="sticky-bar">
|
||||||
<Pill tone="violet" icon="layers">
|
{toDelete.length > 0 ? (
|
||||||
已选 {picked.length} 个
|
<>
|
||||||
</Pill>
|
<Pill tone="neg" icon="trash">
|
||||||
<span className="hint">{picked.join(" · ")}</span>
|
已选 {toDelete.length} 个(待删除)
|
||||||
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
|
</Pill>
|
||||||
对比选中的实验
|
<Btn
|
||||||
</Btn>
|
variant="danger"
|
||||||
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
|
icon="trash"
|
||||||
清空
|
loading={deleting}
|
||||||
</Btn>
|
disabled={deleting}
|
||||||
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
|
onClick={removeSelected}
|
||||||
|
>
|
||||||
|
{deleting ? "删除中…" : "删除所选"}
|
||||||
|
</Btn>
|
||||||
|
<Btn icon="x" disabled={deleting} onClick={() => setToDelete([])}>
|
||||||
|
取消选择
|
||||||
|
</Btn>
|
||||||
|
<span className="hint">
|
||||||
|
范围:当前列表显示的本页 {rows.length} 条
|
||||||
|
{total !== null && total > rows.length
|
||||||
|
? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)`
|
||||||
|
: ""}
|
||||||
|
;删除不可恢复。
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{picked.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{toDelete.length > 0 ? (
|
||||||
|
/* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */
|
||||||
|
<span aria-hidden style={{ width: 1, alignSelf: "stretch", background: "var(--line)" }} />
|
||||||
|
) : null}
|
||||||
|
<Pill tone="violet" icon="layers">
|
||||||
|
已选 {picked.length} 个(对比)
|
||||||
|
</Pill>
|
||||||
|
<span className="hint">{picked.join(" · ")}</span>
|
||||||
|
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
|
||||||
|
对比选中的实验
|
||||||
|
</Btn>
|
||||||
|
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
|
||||||
|
清空
|
||||||
|
</Btn>
|
||||||
|
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -315,7 +555,7 @@ function ExperimentsInner() {
|
|||||||
? `显示 ${exps.length} 条 / 共 ${total} 条${
|
? `显示 ${exps.length} 条 / 共 ${total} 条${
|
||||||
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
|
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
|
||||||
}`
|
}`
|
||||||
: "勾选左侧方框可选 2~3 个做对比"
|
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"
|
||||||
}
|
}
|
||||||
tools={
|
tools={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
@@ -376,9 +616,44 @@ function ExperimentsInner() {
|
|||||||
<table className="tbl">
|
<table className="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 36 }} aria-label="选择对比" />
|
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
|
||||||
|
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
|
||||||
|
<th
|
||||||
|
style={{ width: 36 }}
|
||||||
|
title={
|
||||||
|
total !== null && total > rows.length
|
||||||
|
? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)`
|
||||||
|
: `全选本页(${rows.length} 个)`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label className="check check--cell">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allOnPageSelected}
|
||||||
|
onChange={toggleAllOnPage}
|
||||||
|
aria-label={`全选本页 ${rows.length} 个归档用于删除`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</th>
|
||||||
|
<th style={{ width: 36 }}>
|
||||||
|
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
|
||||||
|
对比
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>类型</th>
|
<th>类型</th>
|
||||||
|
<th>
|
||||||
|
{/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="th-sort"
|
||||||
|
onClick={() => setTimeSort((d) => (d === "desc" ? "asc" : "desc"))}
|
||||||
|
title="按测试发起时间排序,点击切换升序/降序(空值始终排在最后)"
|
||||||
|
aria-label={`按测试发起时间排序:当前${timeSort === "desc" ? "降序" : "升序"},点击切换`}
|
||||||
|
>
|
||||||
|
发起时间 {timeSort === "desc" ? "↓" : "↑"}
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
<th>因子</th>
|
<th>因子</th>
|
||||||
<th>区间</th>
|
<th>区间</th>
|
||||||
<th>摘要</th>
|
<th>摘要</th>
|
||||||
@@ -388,11 +663,21 @@ function ExperimentsInner() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{exps.map((e) => (
|
{rows.map((e) => (
|
||||||
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
|
<tr key={e.id} className={detailId === e.id ? "row-active" : undefined}>
|
||||||
<td>
|
<td>
|
||||||
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
|
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
|
||||||
<label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
|
<label className="check check--cell" title={`选择 ${e.id} 用于批量删除`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toDelete.includes(e.id)}
|
||||||
|
onChange={() => toggleDelete(e.id)}
|
||||||
|
aria-label={`选择 ${e.id} 用于批量删除`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label className="check check--cell" title={`选择 ${e.id} 用于对比(最多 3 个)`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={picked.includes(e.id)}
|
checked={picked.includes(e.id)}
|
||||||
@@ -403,6 +688,9 @@ function ExperimentsInner() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="cell-mono cell-strong">{e.id}</td>
|
<td className="cell-mono cell-strong">{e.id}</td>
|
||||||
<td>{kindView(e.kind)}</td>
|
<td>{kindView(e.kind)}</td>
|
||||||
|
<td className="cell-mono nowrap" title={e.created_at ?? "该归档没有记录发起时间"}>
|
||||||
|
{fmtLocalTime(e.created_at)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="row" style={{ gap: 4 }}>
|
<span className="row" style={{ gap: 4 }}>
|
||||||
{e.factors.map((f) => (
|
{e.factors.map((f) => (
|
||||||
@@ -435,10 +723,22 @@ function ExperimentsInner() {
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: "right" }}>
|
<td style={{ textAlign: "right" }}>
|
||||||
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
|
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
|
||||||
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
{/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开,
|
||||||
|
列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */}
|
||||||
|
<a
|
||||||
|
href={`/experiments/${encodeURIComponent(e.id)}`}
|
||||||
|
className="btn btn--sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
<span>打开归档</span>
|
<span>打开归档</span>
|
||||||
</Link>
|
</a>
|
||||||
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
|
<Btn
|
||||||
|
size="sm"
|
||||||
|
icon="search"
|
||||||
|
// 把触发按钮传进图层:关闭后焦点要回到它(键盘用户不"掉回页首")
|
||||||
|
onClick={(ev) => openDetail(e.id, ev.currentTarget)}
|
||||||
|
>
|
||||||
详情
|
详情
|
||||||
</Btn>
|
</Btn>
|
||||||
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
|
||||||
@@ -463,83 +763,126 @@ function ExperimentsInner() {
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{detail ? (
|
{/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。
|
||||||
<Card
|
「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导
|
||||||
icon="book"
|
(归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导,
|
||||||
title={`${detail.id} · ${detail.factors.join("、")}`}
|
也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */}
|
||||||
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
|
{detailId ? (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
{detailId} 归档详情
|
||||||
|
{detail ? ` · ${experimentKindLabel(detail.kind)}` : ""}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
onClose={closeDetail}
|
||||||
|
returnFocusTo={detailTrigger}
|
||||||
tools={
|
tools={
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<a
|
||||||
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
|
className="btn btn--sm"
|
||||||
<span>打开归档(完整视图)</span>
|
href={`/experiments/${encodeURIComponent(detailId)}`}
|
||||||
</Link>
|
target="_blank"
|
||||||
<Btn size="sm" onClick={() => setDetail(null)}>
|
rel="noreferrer"
|
||||||
<Icon name="x" size={13} />
|
>
|
||||||
关闭
|
<span>在新页面打开完整归档</span>
|
||||||
</Btn>
|
</a>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detailLoading ? (
|
{detailError ? (
|
||||||
<SkeletonLines n={3} />
|
/* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同,
|
||||||
) : (
|
只写「加载失败」等于把可排查的信息丢掉。 */
|
||||||
|
<Banner tone="error">详情加载失败:{detailError}</Banner>
|
||||||
|
) : detailLoading ? (
|
||||||
<>
|
<>
|
||||||
{s ? (
|
<div className="hint" style={{ marginBottom: 8 }}>
|
||||||
<div className="metric-grid" style={{ marginBottom: 0 }}>
|
正在读取归档 {detailId}…
|
||||||
<Metric
|
</div>
|
||||||
label="区间收益"
|
<SkeletonLines n={5} />
|
||||||
tone={s.total_return_pct >= 0 ? "pos" : "neg"}
|
|
||||||
value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
label="年化收益"
|
|
||||||
tone={s.annual_return_pct >= 0 ? "pos" : "neg"}
|
|
||||||
value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`}
|
|
||||||
/>
|
|
||||||
<Metric label="Sharpe" value={s.sharpe.toFixed(2)} />
|
|
||||||
<Metric label="最大回撤" tone="neg" value={`${s.max_drawdown_pct.toFixed(2)}%`} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
|
|
||||||
)}
|
|
||||||
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<LwChart
|
|
||||||
series={[
|
|
||||||
{
|
|
||||||
key: "eq",
|
|
||||||
label: "累计收益率(%)",
|
|
||||||
type: "area",
|
|
||||||
color: CHART.accent,
|
|
||||||
data: normalize(detail.result),
|
|
||||||
lastValueVisible: true,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
height={260}
|
|
||||||
valueFormat={(v) => `${v.toFixed(2)}%`}
|
|
||||||
zeroLine
|
|
||||||
ariaLabel={`${detail.id} 累计收益率曲线`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{detail.spec ? (
|
|
||||||
<details style={{ marginTop: 12 }}>
|
|
||||||
<summary className="hint" style={{ cursor: "pointer" }}>
|
|
||||||
查看 spec(可复现输入)
|
|
||||||
</summary>
|
|
||||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
|
|
||||||
{JSON.stringify(detail.spec, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
|
) : detail ? (
|
||||||
|
<>
|
||||||
|
<div className="kv-grid">
|
||||||
|
<MetaKV label="实验 ID" value={detail.id} mono />
|
||||||
|
<MetaKV label="类型" value={experimentKindLabel(detail.kind)} />
|
||||||
|
<MetaKV label="测试发起时间" value={fmtLocalTime(detail.created_at)} mono />
|
||||||
|
<MetaKV label="代码版本" value={detail.code_version ?? "—"} mono />
|
||||||
|
<MetaKV
|
||||||
|
label="数据版本"
|
||||||
|
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
|
||||||
|
mono
|
||||||
|
warn={!detail.data_version}
|
||||||
|
/>
|
||||||
|
<MetaKV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
|
||||||
|
<MetaKV
|
||||||
|
label="区间"
|
||||||
|
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<MetaKV
|
||||||
|
label="归档体积"
|
||||||
|
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="hint" style={{ margin: "10px 0 14px" }}>
|
||||||
|
结果区与归档页用的是同一个 <code>ArchiveResultView</code>(按 kind 分发),
|
||||||
|
所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里;
|
||||||
|
「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。
|
||||||
|
</div>
|
||||||
|
{detail.result ? (
|
||||||
|
<ArchiveResultView
|
||||||
|
kind={detail.kind}
|
||||||
|
result={detail.result}
|
||||||
|
archive={{
|
||||||
|
id: detail.id,
|
||||||
|
created_at: detail.created_at,
|
||||||
|
code_version: detail.code_version,
|
||||||
|
data_version: detail.data_version,
|
||||||
|
job_id: detail.job_id,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
icon="archive"
|
||||||
|
title="该归档没有可展示的结果"
|
||||||
|
hint="结果为空(可能是历史版本归档)。可用标题栏的「在新页面打开完整归档」,在那边「导出完整 JSON」查看原始内容。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="hint">详情为空 —— 这条归档可能刚被删除,请刷新列表核对。</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。
|
||||||
|
*
|
||||||
|
* 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致,
|
||||||
|
* 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用
|
||||||
|
* 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。
|
||||||
|
*/
|
||||||
|
function MetaKV({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
warn,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
warn?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="kv">
|
||||||
|
<span className="kv__k">{label}</span>
|
||||||
|
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
|
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 图层样式(只服务 components/Modal.tsx)。
|
||||||
|
*
|
||||||
|
* 刻意用 CSS Module 而不是 app/globals.css:全局样式表被多个页面共用,
|
||||||
|
* 而这里的选择器名(backdrop/dialog/head/body)太通用,写进全局极易与别处冲突;
|
||||||
|
* CSS Module 会在构建时把类名变成局部哈希,天然隔离。
|
||||||
|
* 颜色/圆角/间距全部取 globals.css 里已有的设计令牌,保证与全站同一套视觉。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--sp-5);
|
||||||
|
/* 半透明遮罩 + 轻微毛玻璃:背后的列表仍可辨认位置,但不会与图层内容抢注意力 */
|
||||||
|
background: rgba(4, 8, 18, 0.66);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
/* 小屏/长内容时图层本身不超过 90vh,滚动交给 .body */
|
||||||
|
max-height: 90vh;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: var(--surface-1);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
/* 对话框用 tabIndex={-1} 聚焦:去掉浏览器默认的聚焦描边,改用下面的 :focus-visible */
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-4);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg) var(--r-lg) 0 0;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-1);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* 图标按钮用标准控件高度(34px)而不是密集行的 28px:图层是浮层,
|
||||||
|
关闭是唯一出口,点击目标不该比正文按钮更小;字号也调大一档,
|
||||||
|
避免「×」在深色底上细到看不见(截图逐字核对时发现对比度偏低)。 */
|
||||||
|
width: var(--ctl-h-md);
|
||||||
|
height: var(--ctl-h-md);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-size: var(--fs-md);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text-1);
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区:归档结果可以很长(净值曲线 + 持仓 + 成交表),所以这里独立滚动,
|
||||||
|
标题栏与关闭按钮始终可见(不用把用户滚回顶部才能关掉图层)。 */
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--sp-4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用图层(overlay / modal)。
|
||||||
|
*
|
||||||
|
* 为什么自建而不引第三方弹窗库:全站只有「实验详情预览」这一处需要图层,为一个
|
||||||
|
* 需求引入 react-modal / focus-trap 之类依赖不划算。但下面四件事直接影响可用性,
|
||||||
|
* 必须自己做对(不是装饰):
|
||||||
|
* ① **锁 body 滚动**:不锁的话滚轮会滚到背后的列表,用户会以为图层没盖住;
|
||||||
|
* ② **ESC 关闭 + 点遮罩关闭**:且点内容区不关(否则在图层里选中文字、拖滚动条
|
||||||
|
* 都会误关);遮罩判定用 mousedown 而不是 click —— 从内容区按下、拖到遮罩上
|
||||||
|
* 再松手时 click 的目标是两者的共同祖先(遮罩),用 click 会误判为「点了遮罩」;
|
||||||
|
* ③ **焦点管理**:打开时焦点进对话框,关闭后**还给触发它的按钮** —— 否则键盘
|
||||||
|
* 用户关掉图层后焦点回到 <body>,再按 Tab 会从页首重新走一遍;
|
||||||
|
* ④ **role="dialog" + aria-modal + aria-labelledby**:屏幕阅读器才知道「这是一个
|
||||||
|
* 对话框、它的标题是什么、背景内容应当被屏蔽」。
|
||||||
|
*
|
||||||
|
* 用 createPortal 挂到 document.body:图层必须脱离列表页的层叠上下文与 overflow
|
||||||
|
* 裁剪 —— 放在带 transform / overflow:hidden 的祖先里会被裁掉或压不住子元素。
|
||||||
|
*
|
||||||
|
* 样式走 `components/Modal.module.css`(CSS Module),刻意不写进 app/globals.css:
|
||||||
|
* 全局样式表被多处共用,改它容易与并行的改动互相覆盖;图层样式只服务本组件。
|
||||||
|
*/
|
||||||
|
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import styles from "./Modal.module.css";
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
tools,
|
||||||
|
children,
|
||||||
|
returnFocusTo,
|
||||||
|
}: {
|
||||||
|
/** 标题栏文案:同时作为 aria-labelledby 指向的可见标题 */
|
||||||
|
title: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
/** 标题栏右侧动作(如「在新页面打开完整归档」);关闭按钮由本组件自己追加 */
|
||||||
|
tools?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* 关闭后要把焦点还给谁。
|
||||||
|
* 调用方显式传入触发按钮最可靠(例如每行的「详情」按钮);不传时退化为
|
||||||
|
* 「打开那一刻 document.activeElement 是谁」,对点击入口通常也成立。
|
||||||
|
*/
|
||||||
|
returnFocusTo?: HTMLElement | null;
|
||||||
|
}) {
|
||||||
|
const titleId = useId();
|
||||||
|
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
/**
|
||||||
|
* createPortal 需要 document,而 Next 会对客户端组件做一次 SSR:
|
||||||
|
* 直接 portal 会在服务端拿不到 document 而抛错。首帧不渲染,挂载后再上图层。
|
||||||
|
*/
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onClose 每次父组件渲染都是新函数;用 ref 保存,下面的副作用就能只在挂载时
|
||||||
|
* 绑定一次键盘监听 —— 反复解绑/重绑期间正好按下的 ESC 会被漏掉。
|
||||||
|
*/
|
||||||
|
const closeRef = useRef(onClose);
|
||||||
|
useEffect(() => {
|
||||||
|
closeRef.current = onClose;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
const restore = returnFocusTo ?? (document.activeElement as HTMLElement | null);
|
||||||
|
|
||||||
|
// 锁滚动:保存原值而不是关闭时写 "",否则会覆盖页面本来就设置过的 overflow
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// 打开时把焦点移进对话框:tabIndex={-1} 让它可聚焦,但不会进 Tab 序列
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
// 图层是 aria-modal:ESC 只应关掉图层,不应冒泡去触发页面的其它快捷键
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
// 焦点陷阱:不拦住的话 Tab 会跑到图层背后的列表里(视觉上"消失"了)
|
||||||
|
const root = dialogRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const items = Array.from(
|
||||||
|
root.querySelectorAll<HTMLElement>(
|
||||||
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
root.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (e.shiftKey && (active === first || active === root)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
// 触发按钮可能已随列表刷新消失(例如刚删掉这一行):只有还在文档里才回焦,
|
||||||
|
// 否则 focus() 无效,焦点会掉回 <body>(键盘用户会"掉回页首")。
|
||||||
|
if (restore && restore.isConnected) restore.focus();
|
||||||
|
};
|
||||||
|
// 只在挂载/卸载时执行;returnFocusTo 只在打开那一刻读一次,不参与后续渲染
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
// 点遮罩关闭:判定 target === currentTarget,点内容区(子元素)不会命中
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeRef.current();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className={styles.dialog}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className={styles.head}>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div className={styles.tools}>
|
||||||
|
{tools}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.close}
|
||||||
|
onClick={() => closeRef.current()}
|
||||||
|
aria-label="关闭详情图层"
|
||||||
|
title="关闭(ESC)"
|
||||||
|
>
|
||||||
|
<Icon name="x" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user