diff --git a/docs/development/frontend.md b/docs/development/frontend.md index e51cf50..45cce3f 100644 --- a/docs/development/frontend.md +++ b/docs/development/frontend.md @@ -73,12 +73,12 @@ export interface Logger { ### 实现 -| 实现 | 工厂函数 | 用途 | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `DefaultLogger` + Sinks | `useLogger()` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流 | -| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink | -| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 | -| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 | +| 实现 | 工厂函数 | 用途 | +| ----------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| `DefaultLogger` + Sinks | `useLogger(bindings?)` / `createDefaultLogger()` | 组件内使用,ConsoleSink + AntdMessageSink 双流;传入 bindings 自动创建带作用域的子 Logger | +| `ConsoleLogger` | `createConsoleLogger()` | 非组件纯函数(ErrorBoundary、工具函数),仅 ConsoleSink | +| `NoopLogger` | `createNoopLogger()` | 测试中不需要日志的场景 | +| `MemoryLogger` | `createMemoryLogger()` | 测试断言日志条目 | ### 使用方式 @@ -88,7 +88,7 @@ export interface Logger { import { useLogger } from "../hooks/use-logger"; function MyComponent() { - const logger = useLogger(); + const logger = useLogger({ component: "MyComponent" }); logger.info("数据加载完成", { count: 42 }); logger.warn("即将超时"); logger.error("操作失败", { error: new Error("...") }); @@ -106,6 +106,15 @@ logger.debug("调试信息"); **作用域绑定:** +组件内直接通过 `useLogger` 的 `bindings` 参数传入,hook 内部保证引用稳定(值不变时多次渲染返回同一 Logger): + +```typescript +const logger = useLogger({ component: "ChatPanel", page: "workbench" }); +logger.info("页面加载"); // [Alfred:INFO] 页面加载 [component=ChatPanel][page=workbench] +``` + +非组件场景仍可使用 `logger.child()`: + ```typescript const pageLogger = logger.child({ page: "projects" }); pageLogger.info("页面加载"); // [Alfred:INFO] 页面加载 [page=projects] diff --git a/src/web/consoles/workbench/components/chat/ChatPanel.tsx b/src/web/consoles/workbench/components/chat/ChatPanel.tsx index af11342..9f47098 100644 --- a/src/web/consoles/workbench/components/chat/ChatPanel.tsx +++ b/src/web/consoles/workbench/components/chat/ChatPanel.tsx @@ -26,7 +26,7 @@ interface ChatPanelProps { export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) { const { message } = App.useApp(); - const logger = useLogger().child({ component: "ChatPanel", page: "workbench" }); + const logger = useLogger({ component: "ChatPanel", page: "workbench" }); const queryClient = useQueryClient(); const [input, setInput] = useState(""); const [editingMessageId, setEditingMessageId] = useState(null); diff --git a/src/web/hooks/use-logger.ts b/src/web/hooks/use-logger.ts index 1f1b435..18968d0 100644 --- a/src/web/hooks/use-logger.ts +++ b/src/web/hooks/use-logger.ts @@ -1,14 +1,25 @@ import { App } from "antd"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import type { Logger } from "../utils/logger"; import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logger"; -export function useLogger(): Logger { +export function useLogger(bindings?: Record): Logger { const { message } = App.useApp(); + const [stableJson, setStableJson] = useState(() => JSON.stringify(bindings ?? {})); + const [stableBindings, setStableBindings] = useState(() => bindings); + const currentJson = JSON.stringify(bindings ?? {}); + + if (currentJson !== stableJson) { + setStableJson(currentJson); + setStableBindings(bindings); + } + return useMemo(() => { const isProduction = !!import.meta.env["PROD"]; - return createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction); - }, [message]); + const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction); + if (!stableBindings || Object.keys(stableBindings).length === 0) return base; + return base.child(stableBindings); + }, [message, stableBindings]); } diff --git a/tests/web/hooks/use-logger.test.ts b/tests/web/hooks/use-logger.test.ts index 653ce86..6471467 100644 --- a/tests/web/hooks/use-logger.test.ts +++ b/tests/web/hooks/use-logger.test.ts @@ -1,11 +1,23 @@ import { describe, expect, mock, test } from "bun:test"; -import { createElement } from "react"; +import { act, createElement, useState } from "react"; import type { Logger } from "../../../src/web/utils/logger"; import { useLogger } from "../../../src/web/hooks/use-logger"; import { renderWithProviders } from "../test-utils"; +function BindingsHookTester({ + bindings, + onMount, +}: { + bindings?: Record; + onMount: (logger: Logger) => void; +}) { + const logger = useLogger(bindings); + onMount(logger); + return null; +} + function HookTester({ onMount }: { onMount: (logger: Logger) => void }) { const logger = useLogger(); onMount(logger); @@ -70,3 +82,109 @@ describe("useLogger", () => { expect(errorSpy).toHaveBeenCalled(); }); }); + +describe("useLogger bindings 参数", () => { + test("传入 bindings 返回带上下文前缀的 Logger", () => { + const spy = mock((..._args: unknown[]) => {}); + const origWarn = console.warn; + console.warn = spy; + + let logger: Logger | undefined; + renderWithProviders( + createElement(BindingsHookTester, { + bindings: { component: "TestComp" }, + onMount: (l: Logger) => { + logger = l; + }, + }), + ); + + logger!.warn("绑定测试"); + + console.warn = origWarn; + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toMatch(/\[component=TestComp\]/); + }); + + test("无参调用与空 bindings 行为一致", () => { + let noBindingsLogger: Logger | undefined; + let emptyBindingsLogger: Logger | undefined; + + const spy = mock((..._args: unknown[]) => {}); + const origWarn = console.warn; + console.warn = spy; + + renderWithProviders( + createElement(HookTester, { + onMount: (l: Logger) => { + noBindingsLogger = l; + }, + }), + ); + + renderWithProviders( + createElement(BindingsHookTester, { + bindings: {}, + onMount: (l: Logger) => { + emptyBindingsLogger = l; + }, + }), + ); + + noBindingsLogger!.warn("a"); + emptyBindingsLogger!.warn("b"); + + console.warn = origWarn; + expect(spy).toHaveBeenCalledTimes(2); + expect(spy.mock.calls[0]![0]).not.toMatch(/\[component=/); + expect(spy.mock.calls[1]![0]).not.toMatch(/\[component=/); + }); + + test("相同 bindings 值多次渲染返回同一 Logger 引用", () => { + const refs: Logger[] = []; + + function StableTester() { + const logger = useLogger({ component: "Stable" }); + const [, setTick] = useState(0); + refs.push(logger); + return createElement("button", { + onClick: () => setTick((t) => t + 1), + }); + } + + const result = renderWithProviders(createElement(StableTester)); + const initialCount = refs.length; + + act(() => { + result.getByRole("button").click(); + }); + act(() => { + result.getByRole("button").click(); + }); + + expect(refs.length).toBeGreaterThan(initialCount); + for (let i = 1; i < refs.length; i++) { + expect(refs[i]).toBe(refs[0]); + } + }); + + test("bindings 值变化时返回新 Logger 引用", () => { + const refs: Logger[] = []; + let currentBindings: Record = { component: "A" }; + + function DynamicTester() { + const logger = useLogger(currentBindings); + refs.push(logger); + return null; + } + + const { rerender } = renderWithProviders(createElement(DynamicTester)); + const countBefore = refs.length; + + currentBindings = { component: "B" }; + rerender(createElement(DynamicTester)); + + expect(refs.length).toBe(countBefore + 1); + expect(refs[refs.length - 1]).not.toBe(refs[0]); + }); +});