diff --git a/docs/development/frontend.md b/docs/development/frontend.md index cfb6fdb..a1937b8 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -179,3 +179,79 @@ Workbench 聊天页面位于 `src/web/consoles/workbench/pages/ChatPage.tsx`, ## 更新触发条件 修改前端技术栈、组件边界、数据流、样式规则、测试环境或前端验证方式时,必须更新本文档。 + +## 日志模块 + +### Logger 接口 + +`src/web/utils/logger.ts` 提供与后端镜像的 Logger 抽象: + +```typescript +export interface Logger { + child(bindings: Record): Logger; + debug(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; + info(message: string, data?: unknown): void; + setLevel(level: LogLevel): void; + warn(message: string, data?: unknown): void; +} +``` + +### 实现 + +| 实现 | 工厂函数 | 用途 | +| ----------------------- | --------------------------------------- | ------------------------------------------------------- | +| `DefaultLogger` + Sinks | `useLogger()` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流 | +| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink | +| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 | +| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 | + +### 使用方式 + +**组件内(推荐):** + +```typescript +import { useLogger } from "../hooks/use-logger"; + +function MyComponent() { + const logger = useLogger(); + logger.info("数据加载完成", { count: 42 }); + logger.warn("即将超时"); + logger.error("操作失败", { error: new Error("...") }); +} +``` + +**非组件纯函数:** + +```typescript +import { createConsoleLogger } from "../utils/logger"; + +const logger = createConsoleLogger(); +logger.debug("调试信息"); +``` + +**作用域绑定:** + +```typescript +const pageLogger = logger.child({ page: "projects" }); +pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects] +``` + +### notification 红线 + +- `AntdMessageSink` 仅对 **warn**(`message.warning`)和 **error**(`message.error`)触发用户可见通知。 +- `debug` 和 `info` 级别绝不对用户弹出 notification,仅在开发者控制台输出。 +- 错误详情通过 `data` 参数传入(如 `logger.error("提交失败", { error })`),`data` 不经序列化透传,保留 Error 堆栈展开能力。 + +### 生产环境行为 + +生产环境(`import.meta.env["PROD"]`)自动将 ConsoleSink 最小级别设为 `warn`,屏蔽 debug/info 输出。`useLogger()` 和 `createConsoleLogger()` 自动处理此逻辑,调用方无需关心环境判断。 + +### ErrorBoundary 特殊说明 + +`ErrorBoundary` 是 class 组件,无法使用 `useLogger()` hook。它以 `createConsoleLogger()` 直接创建独立的 ConsoleLogger 实例,仅输出到控制台不触发用户通知。 + +### 测试 + +- 单元测试使用 `createMemoryLogger()` 断言日志记录,使用 `createNoopLogger()` 静默无关日志。 +- `createDefaultLogger(sinks, isProduction)` 接受 `isProduction` 参数,测试中可显式控制级别过滤行为,不依赖 `import.meta.env`。 diff --git a/eslint.config.js b/eslint.config.js index 2920b42..93022df 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,9 @@ import tseslint from "typescript-eslint"; const noDirectConsoleMessage = "后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。"; +const noDirectConsoleFrontendMessage = + "前端代码禁止直接使用 console.*;请使用 useLogger() hook(组件内)或 createConsoleLogger()(非组件纯函数)。"; + export default tseslint.config( { ignores: [ @@ -77,6 +80,7 @@ export default tseslint.config( }, { files: ["src/web/**/*.{ts,tsx}"], + ignores: ["src/web/**/logger.ts"], plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, @@ -103,6 +107,13 @@ export default tseslint.config( ], }, ], + "no-restricted-syntax": [ + "error", + { + message: noDirectConsoleFrontendMessage, + selector: "MemberExpression[object.name='console']", + }, + ], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], }, }, diff --git a/openspec/changes/add-frontend-logger/.openspec.yaml b/openspec/changes/add-frontend-logger/.openspec.yaml new file mode 100644 index 0000000..0d3d137 --- /dev/null +++ b/openspec/changes/add-frontend-logger/.openspec.yaml @@ -0,0 +1,2 @@ +schema: fast-drive +created: 2026-06-01 diff --git a/openspec/changes/add-frontend-logger/design.md b/openspec/changes/add-frontend-logger/design.md new file mode 100644 index 0000000..411a855 --- /dev/null +++ b/openspec/changes/add-frontend-logger/design.md @@ -0,0 +1,224 @@ +## 背景 + +当前项目后端通过 `src/server/logger.ts` 提供了统一的 `Logger` 接口和四种实现(PinoLoggerWrapper、ConsoleFallbackLogger、NoopLogger、MemoryLogger),通过依赖注入在运行时代码中使用,并通过 ESLint 的 `no-restricted-syntax` 规则禁止 `src/server/**/*.ts` 直接使用 `console.*`。 + +前端目前没有统一的日志处理。唯一的 console 使用在 `ErrorBoundary.tsx:22` 的 `console.error`,业务错误反馈通过各组件零散调用 `App.useApp().message.error()` 完成。ESLint 对前端 `console.*` 没有任何限制。 + +用户希望: +1. 构造统一的前端日志工具,镜像后端的接口设计和多实现模式 +2. 业务错误(warn 及以上)同时通过 antd notification 和 console 输出 +3. 避免前端调试日志散落为原始 `console.*` 调用 +4. ESLint 按前后端分区管理 console 限制 + +## 讨论记录 + +- **已确认结论**:自己封装前端 Logger,不引入第三方日志库(loglevel / consola 等)。理由:后端已有成熟的接口+多实现模式可直接镜像,核心逻辑不到 100 行,引入库属于过度工程 +- **用户偏好**: + - notification 仅展示 warn 及以上的业务错误信息,debug/info 不给用户看,写入文档作为开发引导 + - `child()` 作用域日志首批实现 + - `data` 参数不做 JSON 序列化,直接透传给 console 以保留 Error 堆栈 + - 生产环境(`import.meta.env.PROD`)屏蔽 warn 以下级别的日志输出 + - Logger 前缀 `[Alfred]` 硬编码在代码中 +- **约束**: + - 不新增依赖 + - 符合 `docs/development/frontend.md` 的前端规约 + - ESLint 保持单文件 + file glob 分区模式 +- **被否决方案**: + - 引入 pino 浏览器版:体积偏大(~15KB gzip),且 pino 的 JSON 结构化在浏览器 DevTools 中不如纯文本直观 + - 引入 loglevel:API 简洁但无原生 Sink 模式,无法优雅集成 antd notification + - 引入 consola:功能全但与后端 pino 风格不一致,增加理解成本 + +## 需求 + +| 需求 | 验收标准 | +| ---- | -------- | +| 统一的前端 Logger 接口 | `src/web/utils/logger.ts` 导出 `Logger` 接口,含 `debug/info/warn/error/child/setLevel` 方法 | +| Console 输出 | 所有级别日志均输出到 console(debug→log, info→log, warn→warn, error→error),带有 `[Alfred:LEVEL]` 前缀和可选附加数据 | +| antd notification 集成 | `useLogger()` hook 自动将 warn 映射为 `message.warning()`,error 映射为 `message.error()`;debug/info 不触发 notification | +| 生产环境静默 | `import.meta.env.PROD` 时 debug/info 不输出到 console 也不触发 notification | +| 作用域日志 | `child(bindings)` 返回带绑定的子 Logger,日志消息自动追加 `[key=value]` 后缀 | +| MemoryLogger 测试替身 | 提供 `createMemoryLogger()`,将日志条目存储在内存数组中以供测试断言 | +| NoopLogger 测试替身 | 提供 `createNoopLogger()`,静默丢弃所有日志 | +| ESLint 前端 console 禁止 | `eslint.config.js` 新增规则:`src/web/**/*.{ts,tsx}` 禁止 `console.*`,`logger.ts` 除外 | +| Error.data 透传 | `logger.error("失败", { error: someError })` 保留 Error 对象的堆栈信息,不做 JSON 序列化 | +| 前缀硬编码 | 日志前缀固定为 `[Alfred]`,不使用模块路径检测 | +| 开发文档补充 | `docs/development/frontend.md` 新增日志模块章节,明确 notification 仅用于 warn+error 的红线 | + +## 目标 / 非目标 + +**目标:** +- 建立与后端一致的 `Logger` 接口和实现模式 +- 提供 `useLogger()` React hook,在组件内自动集成 antd notification +- 通过 ESLint 强制前端使用统一 Logger,杜绝散落的 `console.*` +- 支持测试替身(MemoryLogger / NoopLogger)用于前端单元测试 + +**非目标:** +- 不引入任何第三方日志依赖 +- 不实现前端日志持久化到文件(浏览器无此能力) +- 不改造所有现有组件(渐进式迁移,先建工具再逐步替换) +- 不修改后端 Logger 接口或 ESLint 后端规则 +- 不影响 `message.success()` / `message.info()` 等业务成功反馈的调用方式 + +## 执行约束 + +- **依赖限制**:零新增依赖,纯 TypeScript + React hooks +- **约束**: + - 遵守 `docs/development/frontend.md` 全部规约 + - `useLogger()` 内部使用 `App.useApp()` 获取 antd message 实例 + - 仅 `warn` 和 `error` 级别调用 antd notification + - `data` 参数不加工,直接透传 + - 生产环境 `import.meta.env.PROD` 时 debug/info 静默 +- **质量门禁**: + - `bun test` 全部通过 + - `bun run lint` 零违规 + - `bun run typecheck` 零错误 +- **文档 / 沟通**: + - 更新 `docs/development/frontend.md`,新增"日志模块"章节 + - 新增的 ESLint 规则需在 ESLint 报错信息中包含明确的替代方案(`useLogger()` / `createConsoleLogger()`) + +## 影响范围 + +| 范围 | Artifacts / 参考资料 | 预期变更 | 备注 | +| ---- | -------------------- | -------- | ---- | +| 核心新增 | `src/web/utils/logger.ts` | 新建,Logger 接口 + ConsoleLogger + DefaultLogger + NoopLogger + MemoryLogger + Sink 概念 | 约 150 行 | +| React 集成 | `src/web/hooks/use-logger.ts` | 新建,`useLogger()` hook | 约 30 行 | +| ESLint | `eslint.config.js` | 新增前端 console 禁止区块,排除 `logger.ts` | 约 15 行 | +| 错误边界 | `src/web/components/ErrorBoundary.tsx` | `console.error` 改为 `createConsoleLogger().error()` | 首处改造,只能使用 `createConsoleLogger()` 不可用 `useLogger()` | +| 错误边界测试 | `tests/web/components/ErrorBoundary.test.tsx` | 断言字符串从 `"渲染错误:"` 更新为 `"[Alfred:ERROR] 渲染错误"` | 适配新日志格式 | +| 开发文档 | `docs/development/frontend.md` | 新增"日志模块"章节 | 描述接口、用法、notification 红线 | +| 测试 | `tests/web/utils/logger.test.ts` | 新建,Logger 各实现的单元测试 | 覆盖 ConsoleSink(级别映射/PROD静默/child绑定/data透传)、AntdMessageSink(warn→warning/error→error/debug+info不触发)、MemoryLogger、NoopLogger、setLevel运行时调整、嵌套child追加和覆盖 | +| 测试 | `tests/web/hooks/use-logger.test.ts` | 新建,useLogger hook 单元测试 | 覆盖 antd notification 调用映射 | + +## 决策 + +| 决策 | 理由 | 已否决替代方案 | +| ---- | ---- | -------------- | +| 自己封装而非引入库 | 后端已有成熟模式可镜像,核心逻辑不足 100 行;引入库需额外审计和升级维护;项目原则不引入额外依赖 | pino(浏览器)/loglevel/consola | +| 裸对象透传而不做 JSON 序列化 | 保留 Error 的 `stack` 属性,DevTools 的 Error 对象交互(点击跳转源代码)是重要调试体验 | `JSON.stringify(data)` | +| `useLogger()` 内部使用 `App.useApp()` | 与项目 Ant Design 使用规范一致("需要 message 时在 ConfigProvider 内包裹 App,组件内通过 App.useApp() 获取") | 全局 message 实例 / 独立 Context | +| ESLint 保持单文件 + glob 分区 | 前后端共享 TS/import/perfectionist 规则,分文件产生重复;glob 已提供足够精细的作用域控制 | 拆分为 `eslint.server.js` 和 `eslint.web.js` | +| 生产环境屏蔽 warn 以下级别 | 减少生产环境的 console 噪音,debug 和 info 是面向开发者的调试日志 | 全部保留 / 全部去除 | +| notification 仅 warn + error | 避免过度弹提示干扰用户,debug/info 是开发者调试日志不应暴露给用户 | 全级别都走 notification | + +## 日志模块设计 + +### Logger 接口 + +```ts +type LogLevel = "debug" | "info" | "warn" | "error"; + +interface Logger { + debug(message: string, data?: unknown): void; + info(message: string, data?: unknown): void; + warn(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; + child(bindings: Record): Logger; + setLevel(level: LogLevel): void; +} +``` + +`data` 为 `unknown`,不做 JSON 序列化,直接透传给 console,保留 Error 堆栈和 DevTools 交互能力。 + +### Sink 接口 + +```ts +interface Sink { + write(level: LogLevel, message: string, data: unknown, bindings: Record): void; +} +``` + +每个 Logger 实现持有零个或多个 Sink。DefaultLogger 默认持有 `ConsoleSink` + `AntdMessageSink`。 + +### ConsoleSink + +- 按级别映射:debug/info → `console.log`,warn → `console.warn`,error → `console.error` +- 格式:`[Alfred:LEVEL] message`,后跟 data(如有) +- `bindings` 追加为 ` [key=value][key=value]` 后缀 +- 生产环境(`isProduction=true`)时,debug/info 不输出 + +### AntdMessageSink + +- 接收 antd `MessageInstance`(来自 `App.useApp()`) +- warn → `message.warning(message)`,error → `message.error(message)` +- debug/info 不触发任何 notification +- `data` 和 `bindings` 不传递给 notification,仅传 `message` 字符串 + +### child() 绑定格式 + +```ts +const logger = useLogger().child({ page: "projects", providerId: "abc" }); +logger.warn("供应商删除失败"); +// Console: [Alfred:WARN] 供应商删除失败 [page=projects][providerId=abc] +// Notification: message.warning("供应商删除失败") +``` + +嵌套 child 追加绑定(同 key 覆盖)。 + +### 级别过滤 + +- Logger 构造函数接收 `isProduction: boolean` 参数 +- `createConsoleLogger()` 和 `useLogger()` 内部读取 `import.meta.env.PROD`,使生产环境默认屏蔽 debug/info +- `setLevel(level)` 允许运行时调整最小输出级别,覆盖 `isProduction` 的默认行为,用于开发调试和线上临时排查 + +### 四种实现 + +| 实现 | 构造函数 | Sink | 用途 | +| ---- | -------- | ---- | ---- | +| DefaultLogger | `createDefaultLogger(sinks, isProduction)` | 外部注入 | 生产/开发运行时的双流 Logger (内部类 BaseLogger) | +| ConsoleLogger | `createConsoleLogger()` | ConsoleSink | React 组件外(utils/hooks 纯函数)使用 | +| NoopLogger | `createNoopLogger()` | 无 | 测试静默 | +| MemoryLogger | `createMemoryLogger()` | 内存数组 `entries` | 测试断言,entries 类型 `{ level: LogLevel; message: string; data?: unknown }[]` | + +### 使用场景区分 + +``` +组件内 (有 App.useApp): + const logger = useLogger(); // Console + antd notification + +非组件 (utils / hooks 纯函数): + const logger = createConsoleLogger(); // 仅 console + +测试: + const logger = createMemoryLogger(); // 断言日志内容 + const logger = createNoopLogger(); // 静默丢弃 +``` + +**重要约束**:`useLogger()` 依赖 `App.useApp()`,仅可在 `` 子树内的组件中使用。`ErrorBoundary` 位于 `` 之上(见 `test-utils.tsx:84`),因此 ErrorBoundary 必须使用 `createConsoleLogger()`,不能使用 `useLogger()`。 + +## 执行计划 + +1. 创建 `src/web/utils/logger.ts`:定义 `Logger` 接口、`LogLevel` 类型、`Sink` 概念、`ConsoleSink`、`AntdMessageSink`、`BaseLogger`、`NoopLogger`、`MemoryLogger`、工厂函数 +2. 创建 `src/web/hooks/use-logger.ts`:`useLogger()` hook,组装 BaseLogger + ConsoleSink + AntdMessageSink +3. 修改 `eslint.config.js`:新增前端 `console.*` 禁止区块 +4. 改造 `src/web/components/ErrorBoundary.tsx`:使用 `createConsoleLogger()` +5. 创建 `tests/web/utils/logger.test.ts`:Logger 各实现的单元测试 +6. 创建 `tests/web/hooks/use-logger.test.ts`:useLogger hook 单元测试 +7. 更新 `docs/development/frontend.md`:新增"日志模块"章节 +8. 运行 `bun test && bun run lint && bun run typecheck` 验证 + +## 验证计划 + +| 需求 / 风险 | 验证方式 | +| ----------- | -------- | +| Logger 接口正确性 | `tests/web/utils/logger.test.ts` 覆盖 debug/info/warn/error 级别的 ConsoleLogger/MemoryLogger/NoopLogger | +| 级别过滤 | MemoryLogger 测试:`isProduction=true` 时 debug/info 不记录 | +| child() 作用域 | MemoryLogger 测试:child 绑定后日志包含对应键值 | +| notification 映射 | `tests/web/hooks/use-logger.test.ts`:warn→message.warning,error→message.error | +| notification 不映射 info | use-logger 测试:info 不调用 message 任何方法 | +| ESLint 规则生效 | 手动验证:在 `src/web/` 非 logger.ts 文件写入 `console.log` 触发 lint 报错 | +| 类型检查 | `bun run typecheck` 零错误 | +| Error 堆栈保留 | 手动验证:`logger.error("msg", { error: new Error("test") })` 在 DevTools 中可展开查看堆栈 | +| 文档完整性 | 检查 `docs/development/frontend.md` 包含日志模块章节,含 notification 红线说明 | + +## 风险 / 权衡 + +- [风险] `useLogger()` 依赖 `App.useApp()`,仅能在 `AntApp` 组件内部使用,不可在 utils 纯函数中调用 → 纯函数使用 `createConsoleLogger()`,文档明确说明两种使用场景 +- [风险] antd notification 的 `message` 实例在 React 组件外不可用 → 设计上 `useLogger()` 本身就是 React hook,外部场景用 ConsoleLogger 是预期的 +- [风险] 生产环境屏蔽 debug/info 可能导致线上排查困难 → 可在后续通过 URL 参数或配置开关临时开启,当前不实现以保持简单 +- [风险] 现有组件渐进式改造可能长期不完成 → ESLint 规则确保不再新增 `console.*` 调用,现有代码中的 `console.*`(仅 ErrorBoundary 一处)首批改造 + +## 待解决问题 + +| 状态 | 问题 | 所需决策 | +| ---- | ---- | -------- | +| 无 | 无待解决问题。 | 无需决策 | diff --git a/openspec/changes/add-frontend-logger/tasks.md b/openspec/changes/add-frontend-logger/tasks.md new file mode 100644 index 0000000..5cf9534 --- /dev/null +++ b/openspec/changes/add-frontend-logger/tasks.md @@ -0,0 +1,45 @@ +## 1. 上下文审查 + +- [x] 1.1 阅读 design.md,确认范围、需求、决策、执行约束和待解决问题 +- [x] 1.2 审查后端 `src/server/logger.ts` 的 Logger 接口和实现模式,确保前端镜像的一致性 +- [x] 1.3 审查 `eslint.config.js` 当前 rules 结构,确认新增前端 console 禁止规则的位置和写法 + +## 2. 核心日志模块 + +- [x] 2.1 新建 `src/web/utils/logger.ts`:定义 `LogLevel` 类型、`Logger` 接口(debug/info/warn/error/child/setLevel)、`Sink` 接口 +- [x] 2.2 实现 `ConsoleSink`:按级别映射到 console.log/warn/error,前缀 `[Alfred:LEVEL]`,data 透传不序列化 +- [x] 2.3 实现 `NoopLogger`:所有方法空实现,child 返回自身,静默丢弃日志 +- [x] 2.4 实现 `MemoryLogger`:将日志条目存入 `entries` 数组,用于测试断言 +- [x] 2.5 实现 `ConsoleLogger`:组合 ConsoleSink,生产环境(`import.meta.env.PROD`)屏蔽 debug/info +- [x] 2.6 实现 `AntdMessageSink`:接收 antd MessageInstance,warn→message.warning,error→message.error,debug/info 不触发 +- [x] 2.7 实现 `DefaultLogger`:组合多个 Sink(ConsoleSink + AntdMessageSink),支持 `child()` 作用域绑定 + +## 3. React 集成 + +- [x] 3.1 新建 `src/web/hooks/use-logger.ts`:`useLogger()` 内部调用 `App.useApp()` 获取 message 实例,组装 DefaultLogger + ConsoleSink + AntdMessageSink +- [x] 3.2 改造 `src/web/components/ErrorBoundary.tsx`:`console.error` 替换为 `createConsoleLogger().error()` + +## 4. ESLint 规则 + +- [x] 4.1 在 `eslint.config.js` 新增 `src/web/**/*.{ts,tsx}` 的 `no-restricted-syntax` 规则,禁止 `console.*`,排除 `logger.ts` +- [x] 4.2 运行 `bun run lint` 验证规则生效且无其他文件违规 + +## 5. 测试 + +- [x] 5.1 新建 `tests/web/utils/logger.test.ts`:测试 ConsoleLogger/MemoryLogger/NoopLogger 的 debug/info/warn/error 级别输出 +- [x] 5.2 测试生产环境静默:MemoryLogger 在 `isProduction=true` 时 debug/info 不记录 +- [x] 5.3 测试 `child()` 作用域:绑定后的 Logger 在日志消息中携带对应键值,嵌套 child 追加(同 key 覆盖) +- [x] 5.4 新建 `tests/web/hooks/use-logger.test.ts`:测试 useLogger 返回含 ConsoleSink 的 Logger 实例,warn/error 正常输出到 console(AntdMessageSink 映射在 logger.test.ts 的 AntdMessageSink describe 中测试) + +## 6. 文档 + +- [x] 6.1 更新 `docs/development/frontend.md`,新增"日志模块"章节:Logger 接口、使用方式、notification 红线(仅 warn+error) +- [x] 6.2 确认 `docs/development/frontend.md` 新增的日志模块章节包含 notification 红线、使用场景区分和 ErrorBoundary 特殊说明 + +## 7. 质量门禁 + +- [x] 7.1 运行 `bun test` 确保所有新增和已有测试通过 +- [x] 7.2 运行 `bun run typecheck` 确保零类型错误 +- [x] 7.3 运行 `bun run lint` 确保零 lint 违规 +- [ ] 7.4 手动验证:在 `src/web/` 非 logger.ts 文件临时写入 `console.log("test")` 确认 ESLint 报错 +- [ ] 7.5 手动验证:在浏览器 DevTools 中调用 `logger.error("msg", { error: new Error("test") })` 确认 Error 堆栈可展开 diff --git a/src/web/components/ErrorBoundary.tsx b/src/web/components/ErrorBoundary.tsx index 295a8aa..9ca5cef 100644 --- a/src/web/components/ErrorBoundary.tsx +++ b/src/web/components/ErrorBoundary.tsx @@ -3,6 +3,8 @@ import type { ErrorInfo, ReactNode } from "react"; import { Button, Result } from "antd"; import { Component } from "react"; +import { createConsoleLogger } from "../utils/logger"; + interface Props { children: ReactNode; } @@ -19,7 +21,7 @@ export class ErrorBoundary extends Component { } override componentDidCatch(error: Error, info: ErrorInfo): void { - console.error("渲染错误:", error, info.componentStack); + createConsoleLogger().error("渲染错误", { componentStack: info.componentStack, error }); } override render() { diff --git a/src/web/hooks/use-logger.ts b/src/web/hooks/use-logger.ts new file mode 100644 index 0000000..1f1b435 --- /dev/null +++ b/src/web/hooks/use-logger.ts @@ -0,0 +1,14 @@ +import { App } from "antd"; +import { useMemo } from "react"; + +import type { Logger } from "../utils/logger"; + +import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger"; + +export function useLogger(): Logger { + const { message } = App.useApp(); + return useMemo(() => { + const isProduction = !!import.meta.env["PROD"]; + return createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction); + }, [message]); +} diff --git a/src/web/utils/logger.ts b/src/web/utils/logger.ts new file mode 100644 index 0000000..f99daea --- /dev/null +++ b/src/web/utils/logger.ts @@ -0,0 +1,163 @@ +import type { MessageInstance } from "antd/es/message/interface"; + +export type LogLevel = "debug" | "error" | "info" | "warn"; + +const LEVEL_ORDER: Record = { debug: 0, error: 3, info: 1, warn: 2 }; + +export interface Logger { + child(bindings: Record): Logger; + debug(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; + info(message: string, data?: unknown): void; + setLevel(level: LogLevel): void; + warn(message: string, data?: unknown): void; +} + +export interface Sink { + write(level: LogLevel, message: string, data: unknown, bindings: Record): void; +} + +class AntdMessageSink implements Sink { + constructor(private messageApi: MessageInstance) {} + + write(level: LogLevel, message: string, _data: unknown, _bindings: Record): void { + if (level === "warn") this.messageApi.warning(message); + else if (level === "error") this.messageApi.error(message); + } +} + +class BaseLogger implements Logger { + private minLevel: LogLevel; + + constructor( + minLevel: LogLevel, + protected sinks: Sink[], + protected bindings: Record, + ) { + this.minLevel = minLevel; + } + + child(bindings: Record): Logger { + return new BaseLogger(this.minLevel, this.sinks, { ...this.bindings, ...bindings }); + } + + debug(message: string, data?: unknown): void { + this.log("debug", message, data); + } + + error(message: string, data?: unknown): void { + this.log("error", message, data); + } + + info(message: string, data?: unknown): void { + this.log("info", message, data); + } + + setLevel(level: LogLevel): void { + this.minLevel = level; + } + + warn(message: string, data?: unknown): void { + this.log("warn", message, data); + } + + private log(level: LogLevel, message: string, data?: unknown): void { + if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return; + for (const sink of this.sinks) { + sink.write(level, message, data, this.bindings); + } + } +} + +class ConsoleSink implements Sink { + constructor(private isProduction: boolean) {} + + write(level: LogLevel, message: string, data: unknown, bindings: Record): void { + if (this.isProduction && LEVEL_ORDER[level] < LEVEL_ORDER.warn) return; + + const prefix = `[Alfred:${level.toUpperCase()}]`; + const bindingStr = formatBindings(bindings); + const fullMessage = `${prefix} ${message}${bindingStr}`; + + if (level === "error") console.error(fullMessage, data); + else if (level === "warn") console.warn(fullMessage, data); + else console.log(fullMessage, data); + } +} + +/* eslint-disable @typescript-eslint/no-empty-function */ +class NoopLogger implements Logger { + child(_bindings: Record): Logger { + return this; + } + + debug(_message: string, _data?: unknown): void {} + + error(_message: string, _data?: unknown): void {} + + info(_message: string, _data?: unknown): void {} + + setLevel(_level: LogLevel): void {} + + warn(_message: string, _data?: unknown): void {} +} +/* eslint-enable @typescript-eslint/no-empty-function */ + +export class MemoryLogger implements Logger { + entries: Array<{ data?: unknown; level: LogLevel; message: string }> = []; + + child(bindings: Record): Logger { + void bindings; + return this; + } + + debug(message: string, data?: unknown): void { + this.capture("debug", message, data); + } + + error(message: string, data?: unknown): void { + this.capture("error", message, data); + } + + info(message: string, data?: unknown): void { + this.capture("info", message, data); + } + + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + setLevel(_level: LogLevel): void {} + + warn(message: string, data?: unknown): void { + this.capture("warn", message, data); + } + + private capture(level: LogLevel, message: string, data?: unknown): void { + this.entries.push({ data, level, message }); + } +} + +export function createConsoleLogger(): Logger { + const isProduction = !!import.meta.env["PROD"]; + const minLevel: LogLevel = isProduction ? "warn" : "debug"; + return new BaseLogger(minLevel, [new ConsoleSink(isProduction)], {}); +} + +export function createDefaultLogger(sinks: Sink[], isProduction: boolean): Logger { + const minLevel: LogLevel = isProduction ? "warn" : "debug"; + return new BaseLogger(minLevel, sinks, {}); +} + +export function createMemoryLogger(): MemoryLogger { + return new MemoryLogger(); +} + +export { AntdMessageSink, ConsoleSink }; + +export function createNoopLogger(): Logger { + return new NoopLogger(); +} + +function formatBindings(bindings: Record): string { + const entries = Object.entries(bindings); + if (entries.length === 0) return ""; + return " " + entries.map(([k, v]) => `[${k}=${String(v)}]`).join(""); +} diff --git a/tests/web/components/ErrorBoundary.test.tsx b/tests/web/components/ErrorBoundary.test.tsx index 13e27d1..6f7fd7a 100644 --- a/tests/web/components/ErrorBoundary.test.tsx +++ b/tests/web/components/ErrorBoundary.test.tsx @@ -40,7 +40,7 @@ describe("ErrorBoundary", () => { expect(screen.getByText("渲染错误")).not.toBeNull(); expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull(); expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull(); - expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true); + expect(errors.some((line) => line.includes("[Alfred:ERROR] 渲染错误"))).toBe(true); }); test("点击刷新页面按钮不会破坏错误兜底界面", () => { diff --git a/tests/web/hooks/use-logger.test.ts b/tests/web/hooks/use-logger.test.ts new file mode 100644 index 0000000..df0084a --- /dev/null +++ b/tests/web/hooks/use-logger.test.ts @@ -0,0 +1,74 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import { describe, expect, mock, test } from "bun:test"; +import { createElement } from "react"; + +import type { Logger } from "../../../src/web/utils/logger"; + +import { useLogger } from "../../../src/web/hooks/use-logger"; +import { renderWithProviders } from "../test-utils"; + +function HookTester({ onMount }: { onMount: (logger: Logger) => void }) { + const logger = useLogger(); + onMount(logger); + return null; +} + +describe("useLogger", () => { + test("返回 Logger 实例含所有方法", () => { + let logger: Logger | undefined; + const onMount = (l: Logger) => { + logger = l; + }; + + renderWithProviders(createElement(HookTester, { onMount })); + + expect(logger).toBeDefined(); + expect(typeof logger!.debug).toBe("function"); + expect(typeof logger!.info).toBe("function"); + expect(typeof logger!.warn).toBe("function"); + expect(typeof logger!.error).toBe("function"); + expect(typeof logger!.child).toBe("function"); + expect(typeof logger!.setLevel).toBe("function"); + }); + + test("调用 logger.warn 时静默不抛异常", () => { + const warnSpy = mock(() => {}); + const origWarn = console.warn; + console.warn = warnSpy; + + let logger: Logger | undefined; + renderWithProviders( + createElement(HookTester, { + onMount: (l: Logger) => { + logger = l; + }, + }), + ); + + expect(() => logger!.warn("测试警告")).not.toThrow(); + + console.warn = origWarn; + expect(warnSpy).toHaveBeenCalled(); + }); + + test("调用 logger.error 时静默不抛异常", () => { + const errorSpy = mock(() => {}); + const origError = console.error; + console.error = errorSpy; + + let logger: Logger | undefined; + renderWithProviders( + createElement(HookTester, { + onMount: (l: Logger) => { + logger = l; + }, + }), + ); + + expect(() => logger!.error("测试错误")).not.toThrow(); + + console.error = origError; + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/web/utils/logger.test.ts b/tests/web/utils/logger.test.ts new file mode 100644 index 0000000..3948d93 --- /dev/null +++ b/tests/web/utils/logger.test.ts @@ -0,0 +1,390 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ + +import { describe, expect, mock, test } from "bun:test"; + +import type { Sink } from "../../../src/web/utils/logger"; + +import { + AntdMessageSink, + ConsoleSink, + createConsoleLogger, + createDefaultLogger, + createMemoryLogger, + createNoopLogger, +} from "../../../src/web/utils/logger"; + +describe("ConsoleSink", () => { + test("调试环境输出 debug 级别", () => { + const sink = new ConsoleSink(false); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.log; + console.log = spy; + + sink.write("debug", "测试消息", undefined, {}); + console.log = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:DEBUG\] 测试消息/); + }); + + test("生产环境屏蔽 debug 级别", () => { + const sink = new ConsoleSink(true); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.log; + console.log = spy; + + sink.write("debug", "测试消息", undefined, {}); + console.log = orig; + + expect(spy).toHaveBeenCalledTimes(0); + }); + + test("生产环境屏蔽 info 级别", () => { + const sink = new ConsoleSink(true); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.log; + console.log = spy; + + sink.write("info", "测试消息", undefined, {}); + console.log = orig; + + expect(spy).toHaveBeenCalledTimes(0); + }); + + test("生产环境保留 warn 级别", () => { + const sink = new ConsoleSink(true); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.warn; + console.warn = spy; + + sink.write("warn", "测试消息", undefined, {}); + console.warn = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:WARN\] 测试消息/); + }); + + test("生产环境保留 error 级别", () => { + const sink = new ConsoleSink(true); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.error; + console.error = spy; + + sink.write("error", "测试消息", undefined, {}); + console.error = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:ERROR\] 测试消息/); + }); + + test("绑定信息追加到消息后缀", () => { + const sink = new ConsoleSink(false); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.log; + console.log = spy; + + sink.write("info", "测试消息", undefined, { id: "123", page: "projects" }); + console.log = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:INFO\] 测试消息 \[id=123\]\[page=projects\]/); + }); + + test("data 透传不序列化", () => { + const sink = new ConsoleSink(false); + const spy = mock((..._args: unknown[]) => {}); + const orig = console.error; + console.error = spy; + const err = new Error("测试错误"); + + sink.write("error", "失败", err, {}); + console.error = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[1]).toBe(err); + }); + + test("error 级别映射到 console.error", () => { + const sink = new ConsoleSink(false); + const logSpy = mock((..._args: unknown[]) => {}); + const errorSpy = mock((..._args: unknown[]) => {}); + const origLog = console.log; + const origError = console.error; + console.log = logSpy; + console.error = errorSpy; + + sink.write("error", "错误", undefined, {}); + console.log = origLog; + console.error = origError; + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledTimes(0); + }); + + test("warn 级别映射到 console.warn", () => { + const sink = new ConsoleSink(false); + const logSpy = mock((..._args: unknown[]) => {}); + const warnSpy = mock((..._args: unknown[]) => {}); + const origLog = console.log; + const origWarn = console.warn; + console.log = logSpy; + console.warn = warnSpy; + + sink.write("warn", "警告", undefined, {}); + console.log = origLog; + console.warn = origWarn; + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledTimes(0); + }); +}); + +describe("AntdMessageSink", () => { + test("warn 级别调用 message.warning", () => { + const warningSpy = mock(() => {}); + const messageApi = { + error: mock(() => {}), + info: mock(() => {}), + loading: mock(() => {}), + success: mock(() => {}), + warning: warningSpy, + }; + const sink = new AntdMessageSink(messageApi as never); + + sink.write("warn", "操作警告", undefined, {}); + + expect(warningSpy).toHaveBeenCalledWith("操作警告"); + }); + + test("error 级别调用 message.error", () => { + const errorSpy = mock(() => {}); + const messageApi = { + error: errorSpy, + info: mock(() => {}), + loading: mock(() => {}), + success: mock(() => {}), + warning: mock(() => {}), + }; + const sink = new AntdMessageSink(messageApi as never); + + sink.write("error", "操作失败", undefined, {}); + + expect(errorSpy).toHaveBeenCalledWith("操作失败"); + }); + + test("debug 级别不触发 notification", () => { + const messageApi = { + error: mock(() => {}), + info: mock(() => {}), + loading: mock(() => {}), + success: mock(() => {}), + warning: mock(() => {}), + }; + const sink = new AntdMessageSink(messageApi as never); + + sink.write("debug", "调试消息", undefined, {}); + + expect(messageApi.info).toHaveBeenCalledTimes(0); + expect(messageApi.warning).toHaveBeenCalledTimes(0); + expect(messageApi.error).toHaveBeenCalledTimes(0); + }); + + test("info 级别不触发 notification", () => { + const messageApi = { + error: mock(() => {}), + info: mock(() => {}), + loading: mock(() => {}), + success: mock(() => {}), + warning: mock(() => {}), + }; + const sink = new AntdMessageSink(messageApi as never); + + sink.write("info", "信息消息", undefined, {}); + + expect(messageApi.info).toHaveBeenCalledTimes(0); + expect(messageApi.warning).toHaveBeenCalledTimes(0); + expect(messageApi.error).toHaveBeenCalledTimes(0); + }); +}); + +describe("NoopLogger", () => { + test("所有方法静默不抛异常", () => { + const logger = createNoopLogger(); + + logger.debug("调试"); + logger.info("信息"); + logger.warn("警告"); + logger.error("错误"); + + expect(logger.child({ page: "test" })).toBe(logger); + }); +}); + +describe("MemoryLogger", () => { + test("记录所有级别的日志", () => { + const logger = createMemoryLogger(); + + logger.debug("调试"); + logger.info("信息"); + logger.warn("警告"); + logger.error("错误"); + + expect(logger.entries).toEqual([ + { data: undefined, level: "debug", message: "调试" }, + { data: undefined, level: "info", message: "信息" }, + { data: undefined, level: "warn", message: "警告" }, + { data: undefined, level: "error", message: "错误" }, + ]); + }); + + test("记录附带 data 的日志", () => { + const logger = createMemoryLogger(); + const err = new Error("测试"); + + logger.error("失败", err); + + expect(logger.entries[0]).toEqual({ data: err, level: "error", message: "失败" }); + }); +}); + +describe("DefaultLogger isProduction", () => { + function createSpySink(): { entries: Array<{ data: unknown; level: string; message: string }>; sink: Sink } { + const entries: Array<{ data: unknown; level: string; message: string }> = []; + return { + entries, + sink: { + write(level, message, data) { + entries.push({ data, level, message }); + }, + }, + }; + } + + test("isProduction=true 时 debug/info 不记录", () => { + const spy = createSpySink(); + const logger = createDefaultLogger([spy.sink], true); + + logger.debug("调试"); + logger.info("信息"); + + expect(spy.entries).toHaveLength(0); + }); + + test("isProduction=true 时 warn/error 正常记录", () => { + const spy = createSpySink(); + const logger = createDefaultLogger([spy.sink], true); + + logger.warn("警告"); + logger.error("错误"); + + expect(spy.entries).toHaveLength(2); + expect(spy.entries[0]!.level).toBe("warn"); + expect(spy.entries[1]!.level).toBe("error"); + }); + + test("isProduction=false 时 debug/info 正常记录", () => { + const spy = createSpySink(); + const logger = createDefaultLogger([spy.sink], false); + + logger.debug("调试"); + logger.info("信息"); + + expect(spy.entries).toHaveLength(2); + expect(spy.entries[0]!.level).toBe("debug"); + expect(spy.entries[1]!.level).toBe("info"); + }); +}); + +describe("child() 作用域", () => { + test("child 绑定信息输出到日志前缀", () => { + const spy = mock((..._args: unknown[]) => {}); + const orig = console.warn; + console.warn = spy; + const sink = new ConsoleSink(false); + const logger = createDefaultLogger([sink], false).child({ page: "projects" }); + logger.warn("测试"); + + console.warn = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=projects\]/); + }); + + test("嵌套 child 追加绑定", () => { + const spy = mock((..._args: unknown[]) => {}); + const orig = console.warn; + console.warn = spy; + const sink = new ConsoleSink(false); + const logger = createDefaultLogger([sink], false).child({ page: "projects" }).child({ action: "delete" }); + logger.warn("测试"); + + console.warn = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=projects\]\[action=delete\]/); + }); + + test("嵌套 child 同 key 覆盖", () => { + const spy = mock((..._args: unknown[]) => {}); + const orig = console.warn; + console.warn = spy; + const sink = new ConsoleSink(false); + const logger = createDefaultLogger([sink], false).child({ page: "projects" }).child({ page: "models" }); + logger.warn("测试"); + + console.warn = orig; + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]!; + expect(call[0]).toMatch(/\[Alfred:WARN\] 测试 \[page=models\]/); + }); +}); + +describe("setLevel 运行时调整", () => { + test("setLevel 可提高最小输出级别", () => { + const spy = (() => { + const entries: Array<{ level: string; message: string }> = []; + return { + entries, + sink: { + write(level: string, message: string) { + entries.push({ level, message }); + }, + }, + }; + })(); + const logger = createDefaultLogger([spy.sink], false); + + logger.debug("调试"); + expect(spy.entries).toHaveLength(1); + + logger.setLevel("error"); + logger.debug("调试2"); + logger.warn("警告"); + logger.error("错误"); + + expect(spy.entries).toHaveLength(2); + expect(spy.entries[1]!.level).toBe("error"); + expect(spy.entries[1]!.message).toBe("错误"); + }); +}); + +describe("createConsoleLogger", () => { + test("返回非 null Logger 实例", () => { + const logger = createConsoleLogger(); + expect(logger).not.toBeNull(); + expect(typeof logger.debug).toBe("function"); + expect(typeof logger.info).toBe("function"); + expect(typeof logger.warn).toBe("function"); + expect(typeof logger.error).toBe("function"); + expect(typeof logger.child).toBe("function"); + expect(typeof logger.setLevel).toBe("function"); + }); +});