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:
Simon
2026-10-01 18:31:04 +08:00
parent 57d6082f91
commit c974415691
3 changed files with 705 additions and 97 deletions
+101
View File
@@ -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);
}
+164
View File
@@ -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
);
}