首页
/ OmniRoute 控制台日志查看器可访问性实践:图标控件的可访问名称、键盘焦点与安全复制反馈

OmniRoute 控制台日志查看器可访问性实践:图标控件的可访问名称、键盘焦点与安全复制反馈

2026-09-07 17:45:34作者:韦蓉瑛

导读

本文以 OmniRoute 仓库中一条 UI 修复记录(changelog.d/fixes/11599-console-log-controls-accessibility.md)为主线,剖析其改动落点——Dashboard「Console 日志查看器」中刷新(Refresh)与复制(Copy)两个纯图标控件。通过阅读组件源码、API 路由与配套测试,你将理解:图标按钮为什么要补可访问名称、如何保证键盘(Tab)聚焦时动作仍可见、复制成功提示如何借助 ARIA live region 被屏幕阅读器安全播报而不产生泄漏。这些做法同样适用于任何"图标式工具栏 + 实时日志/表格"类管理界面。


一、这条 changelog 片段在讲什么

仓库采用 changelog fragment(变更记录片段)机制:每个 PR 在 changelog.d 对应分类目录(features/fixes/maintenance/)下新增一个文件,发布时由脚本聚合并写入 CHANGELOG.md,具体约定见 changelog.d/README.md。本次关联文档即 fixes/ 下的 #11599 片段,原文为:

fix(ui): Console log Refresh and Copy controls now expose localized accessible names, keep copy actions visible on keyboard focus, and announce copy completion safely (#11599) — thanks @pacocartones

一句话可以拆出三个要点:

  1. localized accessible names —— Refresh、Copy 两个图标按钮获得基于多语言词条的、可被屏幕阅读器读出的名称;
  2. visible on keyboard focus —— 复制动作按钮默认靠鼠标悬停显现,但通过键盘 Tab 聚焦时也必须可见可操作;
  3. announce copy completion safely —— 复制完成后通过可访问的实时区域安全地播报"已复制",且多次连续复制、组件卸载等场景下都不会出错或泄漏定时器。

下面结合源码逐项还原。


二、功能背景:Console Log Viewer 是什么

2.1 页面与组件

控制台日志页由 src/app/(dashboard)/dashboard/logs/console/page.tsx 承载,它把渲染全部委托给核心组件 src/shared/components/ConsoleLogViewer.tsx

该组件是一个"终端风"的实时应用日志查看器,其自身注释明确写出它的定位:

Displays structured application logs from the server with a terminal-like UI. Polls the backend API every 5 seconds. Shows logs from the last 1 hour. Supports level filtering, text search, auto-scroll, and copy-to-clipboard.

关键行为包括:

  • 每 5 秒轮询一次后端 API(POLL_INTERVAL = 5000),同时支持手动刷新;
  • 默认只展示最近 1 小时日志,单次最多取 500 条;
  • 工具栏提供日志级别过滤(Debug+/Info+/Warn+/Error+)、全文检索、自动滚动开关;
  • 每条日志右侧有悬停即现的复制按钮,点击可把整条结构化日志(JSON 格式化后)写入剪贴板。

2.2 数据来源与前置条件

前端请求 GET /api/logs/console,实现在 src/app/api/logs/console/route.ts。该路由读取应用日志文件(路径由 src/lib/logEnv.tsgetAppLogFilePath() 提供),逐行解析 pino 风格的结构化 JSON 日志,再按时间(1 小时内)、级别阈值、组件名过滤,并统一把 time/timestamplevelmsg/message 等字段归一化后返回,避免畸形日志导致渲染崩溃。

值得注意的前置条件:只有应用开启了文件日志(环境变量 APP_LOG_TO_FILE=true,页面空态与错误态文案均有提示,见下文 i18n 键 fileLoggingRequiredemptyFileLoggingHint)时,查看器才有数据可读;否则日志文件不存在,路由返回空数组,界面显示"Make sure the application is writing logs to a file"引导文案。

2.3 查看器自身的 ARIA 骨架

在谈论本次修复前,先看组件已有的无障碍基础:

<div
  ref={scrollRef}
  className="rounded-xl border ..."
  role="log"
  aria-label={tv("consoleAria")}
  aria-live="polite"
>

日志输出容器声明了 role="log"aria-live="polite",让新增日志可以被辅助技术以不打断用户的方式感知。但仅靠容器还不够:工具栏上 Refresh 是纯图标按钮,列表里每条日志的 Copy 也是纯图标按钮——这正是本次修复的对象。


三、修复一:图标控件的可访问名称与本地化

3.1 Refresh 按钮

参考 src/shared/components/ConsoleLogViewer.tsx 中刷新按钮的实现:

{/* Refresh */}
<button
  onClick={fetchLogs}
  disabled={loading}
  aria-label={tc("refresh")}
  className="px-3 py-2 rounded-lg text-sm font-medium bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] hover:bg-[var(--color-bg-alt)] disabled:opacity-50 transition-colors"
>
  <span className="material-symbols-outlined text-[16px] align-middle" aria-hidden="true">
    refresh
  </span>
</button>

要点:

  • 按钮内唯一的可感知内容是一个 Material Symbols 图标(字形 refresh)。屏幕阅读器无法"读出"图标字形,因此通过 aria-label={tc("refresh")} 提供程序化名称;
  • 图标 <span> 标了 aria-hidden="true",避免辅助技术把图标文字与 aria-label 重复朗读;
  • 名称来源 tc = useTranslations("common"),即公共词条 common.refresh。在 src/i18n/messages/en.json 中该键的英文值为 "Refresh"。由于名称走 next-intl 词条而非硬编码字符串,组件能随语言切换自动本地化——这正是 changelog 所说 "localized accessible names" 的含义。

3.2 Copy 按钮

每条日志右侧的复制按钮(ConsoleLogViewer.tsx):

{/* Copy button */}
<button
  onClick={() => handleCopy(entry, idx)}
  title={tv("copyLogEntry")}
  aria-label={tv("copyLogEntry")}
  className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
>
  <span className="material-symbols-outlined text-[14px]" aria-hidden="true">
    {copiedIdx === idx ? "check" : "content_copy"}
  </span>
</button>
{copiedIdx === idx && (
  <span className="sr-only" role="status" aria-live="polite">
    {tc("copied")}
  </span>
)}

它的可访问名称来自 tv = useTranslations("logs.consoleViewer") 命名空间下的 copyLogEntry(英文 "Copy log entry"),同时 title 也给出提示,兼顾鼠标悬浮与无障碍两者;图标同样 aria-hidden="true"。图标本身会根据 copiedIdx === idxcontent_copycheck 之间切换,给视觉用户以即时反馈。

3.3 相关 i18n 词条一览

logs.consoleViewer 命名空间在 src/i18n/messages/en.json(en 版本约 L4924-L4941)中的键集合如下:

英文文案 用途
fetchFailed Failed to fetch logs 拉取失败提示
copyFailed Failed to copy log entry 复制失败提示(role="alert"
copyLogEntry Copy log entry 复制按钮可访问名称 / title
filterByLevel Filter by log level 级别下拉框 aria-label
searchPlaceholder Search logs… 搜索框占位符
searchAria Search log entries 搜索框 aria-label
disableAutoScroll / enableAutoScroll 自动滚动切换按钮 title
autoScroll Auto-scroll 自动滚动按钮可见文本
entryCount # entry / # entries 条目数(复数规则)
lastHour Last 1h 时间窗口标签
updatedAt Updated {time} 最近更新时间
fileLoggingRequired / emptyFileLoggingHint 引导开启 APP_LOG_TO_FILE=true 空态 / 错误态说明
consoleAria Application console logs 日志区 aria-label
applicationConsole Application Console 顶栏标题

同时复用的公共词条有 common.refresh"Refresh")与 common.copied"Copied!")。仓库在 src/i18n/messages 下为各语言维护了同名 JSON(含 zh-CN、ja、ko、de、fr、es 等),并由 config/i18n-schema.jsonconfig/i18n.json 约束键结构——从源码结构看,新增/调整可访问名称词条时必须同步各语言文件,才能让无障碍名称在各语言界面下保持一致。


四、修复二:键盘焦点下的复制动作可见性

默认情况下复制按钮样式为 opacity-0(透明),只有鼠标悬停到整行(group-hover:opacity-100)时才显现。问题在于:纯键盘用户 Tab 聚焦时,按钮若保持透明,用户会看不到焦点落在哪

本次通过 focus-visible:opacity-100 解决:

className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity shrink-0 text-[#8b949e] hover:text-white"
  • :focus-visible 只在高亮"键盘焦点"时生效(例如 Tab 键导航),鼠标点击时通常不触发,避免了视觉上焦点样式与点击样式互相干扰;
  • 因此键盘用户逐行 Tab 经过复制按钮时,按钮变为可见,配合浏览器默认焦点外轮廓即可完成"我能看见我在哪里、可以回车执行"的操作闭环;
  • 该按钮本身语义是原生 <button>,天然支持 Enter/Space 触发,无需额外键盘事件处理。

这也解释了配套测试中为什么专门断言按钮 className 包含 focus-visible:opacity-100(见下一节)。


五、修复三:复制完成的"安全播报"

5.1 异步复制与失败反馈

复制逻辑 handleCopyConsoleLogViewer.tsx):

const handleCopy = async (entry: LogEntry, idx: number) => {
  const text = JSON.stringify(entry, null, 2);
  const success = await copyToClipboard(text);
  if (!success) {
    setError(tv("copyFailed"));
    return;
  }

  setError(null);
  if (copyFeedbackTimerRef.current) clearTimeout(copyFeedbackTimerRef.current);
  setCopiedIdx(idx);
  copyFeedbackTimerRef.current = setTimeout(() => {
    copyFeedbackTimerRef.current = null;
    setCopiedIdx(null);
  }, 2000);
};
  • 整条日志先被 JSON.stringify(entry, null, 2) 格式化为易读 JSON;
  • 复制能力来自工具函数 copyToClipboardsrc/shared/utils/clipboard.ts),它返回布尔结果;失败时设置错误状态,错误条以 role="alert" 呈现,可被立即播报。

5.2 成功播报的 ARIA 实现

复制成功后,界面做两件事:

  1. 视觉反馈:按钮图标由 content_copy 切换为 check
  2. 屏幕阅读器反馈:条件渲染一个 sr-only(视觉隐藏但对辅助技术可见)的元素:
<span className="sr-only" role="status" aria-live="polite">
  {tc("copied")}
</span>

role="status" + aria-live="polite" 表示这是一个不打断当前操作的礼貌实时区域,内容插入后屏幕阅读器会读出 "Copied!"common.copied)。

5.3 "安全"体现在哪三个细节

结合源码与测试,"announce copy completion safely"可拆解为三重保障:

  1. 单一定时器 + 取消前序。每次复制前先 clearTimeout(copyFeedbackTimerRef.current) 再设置新定时器,且成功后把引用置空。连续复制多条时,播报不会被前一条的旧定时器提前抹掉,最新一次的"已复制"状态能保持完整的 2 秒展示周期;
  2. 卸载清理。组件通过如下 effect 在卸载时清除未完成的定时器(L93-L98):
useEffect(
  () => () => {
    if (copyFeedbackTimerRef.current) clearTimeout(copyFeedbackTimerRef.current);
  },
  []
);

这样即使复制提示尚未消失就切换页面,也不会发生定时器泄漏或卸载后 setState 的隐患; 3. 加载与失败不影响播报。复制失败走 role="alert" 错误通道,成功播报走 role="status" 通道,两者互不污染;复制同时清除历史错误提示,避免残留告警干扰。


六、配套自动化测试:把可访问性固化为契约

本次修复配有一份专门的 jsdom 单测 tests/unit/ui/console-log-viewer-accessibility.test.tsx,它 mock 掉 next-intl(把 useTranslations(namespace) 变为返回 ${namespace}.${key},从而可直接断言词条归属)与 @/shared/utils/clipboard,并用 fake timers 精确控制 2 秒反馈窗口。三个用例覆盖了 changelog 描述的全部行为:

用例 断言重点
names icon-only controls and exposes keyboard-visible copy feedback Refresh 按钮 aria-labelcommon.refresh、图标 aria-hidden="true";Copy 按钮 aria-labellogs.consoleViewer.copyLogEntry 且含 focus-visible:opacity-100;点击后出现 role="status" 文本 common.copied,2 秒后消失
keeps the latest copy announcement for its full timeout 依次点击两条日志的复制按钮,中间间隔 1 秒:确认第二次点击不会让第一次的定时器提前清除播报,提示按最后一次点击起算满 2 秒后消失
clears pending copy feedback when unmounted 点击复制后立即卸载组件,断言定时器计数归零,杜绝内存泄漏

用仓库的 vitest 配置即可运行该测试,例如:

npx vitest run tests/unit/ui/console-log-viewer-accessibility.test.tsx

从测试的 mock 方式可以推断一个工程约定:可访问名称的正确性以"词条命名空间 + 键"为准绳,而不是校验某个具体语言的翻译文本——这保证任意语言环境下同一控件都具备名称,避免出现"只有英文界面无障碍、其他语言界面退化回无名称图标"的典型回归。


七、小结:一类可复用的无障碍模式

透过 #11599 这条小修复,可以提炼出一套适用于"图标式工具栏 + 实时数据列表"界面的可复用经验:

  1. 图标按钮必须有名:任何只有图形内容的交互控件,都要通过 aria-label(优先取 i18n 词条)给出可访问名称,图标元素一律 aria-hidden="true" 防止重复朗读;
  2. 键盘可达不等于键盘可见:对"悬停显示"的隐藏式操作,务必补充 focus-visible 分支,保证 Tab 聚焦时动作按钮可见可用;
  3. 复制/操作完成要播报:用 sr-only + role="status"/aria-live="polite" 呈现瞬时状态,视觉隐藏但对读屏可见;
  4. 异步反馈要"安全":反馈用单一 ref 持有定时器,先清后设保证"最新状态赢得展示期",并在组件卸载时统一清理;
  5. 把可访问性写成测试:用 namespace.key 断言可访问名称、用 fake timers 断言播报生命周期,把无障碍要求沉淀为不会回退的契约。

对本仓库而言,Console Log Viewer 只是诸多管理页面之一;但 ConsoleLogViewer.tsx 中这套"可访问名称 + 焦点可见性 + live region 播报 + 定时器安全"的组合实现,与 tests/unit/ui/console-log-viewer-accessibility.test.tsx 一起,构成了一个完整、可复制的无障碍改造范例。

登录后查看全文
热门项目推荐
相关项目推荐