diff --git a/frontend/web/app/experiments/page.tsx b/frontend/web/app/experiments/page.tsx index f3450f1..d3ba25a 100644 --- a/frontend/web/app/experiments/page.tsx +++ b/frontend/web/app/experiments/page.tsx @@ -11,8 +11,15 @@ * 微调时一眼看出「这次到底改了什么」。 * * 曲线用 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 { useSearchParams } from "next/navigation"; import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api"; @@ -26,13 +33,14 @@ import { Btn, Banner, Empty, - Metric, SkeletonLines, Loading, } from "@/components/ui"; import { Icon } from "@/components/icons"; 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`)。 @@ -94,6 +102,40 @@ function shortDataVersion(v: string): string { 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" }[] = [ { 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" }, @@ -124,9 +166,19 @@ function ExperimentsInner() { const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(""); + /** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */ + const [detailId, setDetailId] = useState(null); + /** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */ + const [detailTrigger, setDetailTrigger] = useState(null); const [jobId, setJobId] = useState(""); const [jobStatus, setJobStatus] = useState(""); const [error, setError] = useState(""); + /** + * 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。 + * tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。 + */ + const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null); // 对比状态 const [picked, setPicked] = useState([]); @@ -134,6 +186,16 @@ function ExperimentsInner() { const [comparing, setComparing] = useState(false); const [onlyDiff, setOnlyDiff] = useState(true); + // 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**—— + // 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作, + // 不该和「我想比一比」共用一次勾选(误删代价太大)。 + const [toDelete, setToDelete] = useState([]); + const [deleting, setDeleting] = useState(false); + + // 列表排序:默认按发起时间倒序(与后端 list_filtered 的 created_at desc 一致), + // 点表头在升降之间切换。 + const [timeSort, setTimeSort] = useState<"desc" | "asc">("desc"); + // 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。 // 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢, // 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。 @@ -186,13 +248,113 @@ function ExperimentsInner() { // 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); - apiGet(`/experiments/${id}`) - .then((d) => setDetail(d)) - .catch((e: Error) => setError(e.message)) - .finally(() => setDetailLoading(false)); + setDetailError(""); + setDetailLoading(true); + apiGet(`/experiments/${encodeURIComponent(id)}`) + .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("/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) { @@ -218,7 +380,8 @@ function ExperimentsInner() { if (j.status === "success" || j.status === "failed" || tries > 60) { window.clearInterval(timer); load(); - setDetail(null); + // 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串 + closeDetail(); } }) .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 ( <> @@ -276,19 +479,56 @@ function ExperimentsInner() { {error ? {error} : null} - {picked.length > 0 ? ( + {/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */} + {notice ? {notice.text} : null} + + {picked.length > 0 || toDelete.length > 0 ? (
- - 已选 {picked.length} 个 - - {picked.join(" · ")} - - 对比选中的实验 - - { setPicked([]); setCompare(null); }}> - 清空 - - {picked.length < 2 ? 再选 1 个即可对比 : null} + {toDelete.length > 0 ? ( + <> + + 已选 {toDelete.length} 个(待删除) + + + {deleting ? "删除中…" : "删除所选"} + + setToDelete([])}> + 取消选择 + + + 范围:当前列表显示的本页 {rows.length} 条 + {total !== null && total > rows.length + ? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)` + : ""} + ;删除不可恢复。 + + + ) : null} + {picked.length > 0 ? ( + <> + {toDelete.length > 0 ? ( + /* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */ + + ) : null} + + 已选 {picked.length} 个(对比) + + {picked.join(" · ")} + + 对比选中的实验 + + { setPicked([]); setCompare(null); }}> + 清空 + + {picked.length < 2 ? 再选 1 个即可对比 : null} + + ) : null}
) : null} @@ -315,7 +555,7 @@ function ExperimentsInner() { ? `显示 ${exps.length} 条 / 共 ${total} 条${ total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : "" }` - : "勾选左侧方框可选 2~3 个做对比" + : "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比" } tools={
@@ -376,9 +616,44 @@ function ExperimentsInner() { - + + @@ -388,11 +663,21 @@ function ExperimentsInner() { - {exps.map((e) => ( - + {rows.map((e) => ( + + +
+ {/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开, + 因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */} + rows.length + ? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)` + : `全选本页(${rows.length} 个)` + } + > + + + + 对比 + + ID 类型 + {/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */} + + 因子 区间 摘要
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */} - + {e.id} {kindView(e.kind)} + {fmtLocalTime(e.created_at)} + {e.factors.map((f) => ( @@ -435,10 +723,22 @@ function ExperimentsInner() { - + {/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开, + 列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */} + 打开归档 - - open(e.id)}> + + openDetail(e.id, ev.currentTarget)} + > 详情 @@ -463,83 +763,126 @@ function ExperimentsInner() { )} - {detail ? ( - + {detailId} 归档详情 + {detail ? ` · ${experimentKindLabel(detail.kind)}` : ""} + + } + onClose={closeDetail} + returnFocusTo={detailTrigger} tools={ -
- - 打开归档(完整视图) - - setDetail(null)}> - - 关闭 - -
+ + 在新页面打开完整归档 + } > - {detailLoading ? ( - - ) : ( + {detailError ? ( + /* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同, + 只写「加载失败」等于把可排查的信息丢掉。 */ + 详情加载失败:{detailError} + ) : detailLoading ? ( <> - {s ? ( -
- = 0 ? "pos" : "neg"} - value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`} - /> - = 0 ? "pos" : "neg"} - value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`} - /> - - -
- ) : ( -
该实验没有可展示的汇总结果(可能是因子测试或历史版本)。
- )} - {detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? ( -
- `${v.toFixed(2)}%`} - zeroLine - ariaLabel={`${detail.id} 累计收益率曲线`} - /> -
- ) : null} - {detail.spec ? ( -
- - 查看 spec(可复现输入) - -
-                    {JSON.stringify(detail.spec, null, 2)}
-                  
-
- ) : null} +
+ 正在读取归档 {detailId}… +
+ + ) : detail ? ( + <> +
+ + + + + + + + +
+
+ 结果区与归档页用的是同一个 ArchiveResultView(按 kind 分发), + 所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里; + 「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。 +
+ {detail.result ? ( + + ) : ( + + )} + + ) : ( +
详情为空 —— 这条归档可能刚被删除,请刷新列表核对。
)} -
+ ) : null} ); } +/** + * 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。 + * + * 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致, + * 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用 + * 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。 + */ +function MetaKV({ + label, + value, + mono, + warn, +}: { + label: string; + value: string; + mono?: boolean; + warn?: boolean; +}) { + return ( +
+ {label} + {value} +
+ ); +} + /* ------------------------------------------------------------------ */ /** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */ diff --git a/frontend/web/components/Modal.module.css b/frontend/web/components/Modal.module.css new file mode 100644 index 0000000..8806fe5 --- /dev/null +++ b/frontend/web/components/Modal.module.css @@ -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); +} \ No newline at end of file diff --git a/frontend/web/components/Modal.tsx b/frontend/web/components/Modal.tsx new file mode 100644 index 0000000..c044ace --- /dev/null +++ b/frontend/web/components/Modal.tsx @@ -0,0 +1,164 @@ +"use client"; + +/** + * 通用图层(overlay / modal)。 + * + * 为什么自建而不引第三方弹窗库:全站只有「实验详情预览」这一处需要图层,为一个 + * 需求引入 react-modal / focus-trap 之类依赖不划算。但下面四件事直接影响可用性, + * 必须自己做对(不是装饰): + * ① **锁 body 滚动**:不锁的话滚轮会滚到背后的列表,用户会以为图层没盖住; + * ② **ESC 关闭 + 点遮罩关闭**:且点内容区不关(否则在图层里选中文字、拖滚动条 + * 都会误关);遮罩判定用 mousedown 而不是 click —— 从内容区按下、拖到遮罩上 + * 再松手时 click 的目标是两者的共同祖先(遮罩),用 click 会误判为「点了遮罩」; + * ③ **焦点管理**:打开时焦点进对话框,关闭后**还给触发它的按钮** —— 否则键盘 + * 用户关掉图层后焦点回到 ,再按 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(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( + '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() 无效,焦点会掉回 (键盘用户会"掉回页首")。 + if (restore && restore.isConnected) restore.focus(); + }; + // 只在挂载/卸载时执行;returnFocusTo 只在打开那一刻读一次,不参与后续渲染 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mounted]); + + if (!mounted) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) closeRef.current(); + }} + > +
+
+

+ {title} +

+
+ {tools} + +
+
+
{children}
+
+
, + document.body + ); +} \ No newline at end of file