Files
Simon c974415691 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 倍放大截图逐字核对,
  原先「×」细到几乎看不见,现在与「在新页面打开完整归档」同高同线、清晰可见。
2026-10-01 18:31:04 +08:00

164 lines
6.4 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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
);
}