diff --git a/frontend/src/contexts/ChannelContext.tsx b/frontend/src/contexts/ChannelContext.tsx index a507e93..1429b0a 100644 --- a/frontend/src/contexts/ChannelContext.tsx +++ b/frontend/src/contexts/ChannelContext.tsx @@ -5,7 +5,7 @@ * @author Color2333 */ -import { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react'; +import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useMemo } from 'react'; import { resolveApiBase } from '@/services/api'; export interface Channel { @@ -79,19 +79,21 @@ export function ChannelProvider({ children }: { children: ReactNode }) { setDefaultChannels(ids); }, []); + // value useMemo:各 callback 已稳定,仅依赖数据变化时重建 value,避免每次 Provider render + // 新建 value 对象导致所有 useChannels 消费者重渲染 + const value = useMemo(() => ({ + channels, + defaultChannels, + loading, + error, + getChannel, + updateChannelStatus, + setDefaultChannels: setDefault, + refreshChannels: fetchChannels, + }), [channels, defaultChannels, loading, error, getChannel, updateChannelStatus, setDefault, fetchChannels]); + return ( - + {children} ); diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx index 5da05e2..eb71680 100644 --- a/frontend/src/contexts/ToastContext.tsx +++ b/frontend/src/contexts/ToastContext.tsx @@ -2,7 +2,7 @@ * 全局 Toast 通知上下文 * @author Color2333 */ -import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -34,8 +34,11 @@ export function ToastProvider({ children }: { children: ReactNode }) { setTimeout(() => dismiss(id), 3500); }, [dismiss]); + // value useMemo:toast/dismiss 已是 useCallback(稳定),仅 toasts 变化时重建 value, + // 避免每次 Provider render 都新建 value 对象导致所有 useToast 消费者重渲染 + const value = useMemo(() => ({ toasts, toast, dismiss }), [toasts, toast, dismiss]); return ( - + {children} ); diff --git a/frontend/src/pages/Agent.tsx b/frontend/src/pages/Agent.tsx index 17aeb51..549e9be 100644 --- a/frontend/src/pages/Agent.tsx +++ b/frontend/src/pages/Agent.tsx @@ -239,6 +239,13 @@ export default function Agent() { [handleConfirm] ); + // useCallback 稳定化:setCanvas 是 useState setter(稳定),避免每次 render 新建函数击穿 ChatBlock memo + const handleOpenArtifact = useCallback( + (title: string, content: string, isHtml?: boolean) => + setCanvas({ title, markdown: content, isHtml }), + [setCanvas], + ); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -261,7 +268,7 @@ export default function Agent() { className="relative flex-1 overflow-y-auto" > {items.length === 0 ? ( - handleSend(p)} /> + ) : (
{items.map((item, idx) => { @@ -284,9 +291,7 @@ export default function Agent() { isConfirming={item.actionId ? confirmingActions.has(item.actionId) : false} onConfirm={handleConfirmAction} onReject={handleReject} - onOpenArtifact={(title, content, isHtml) => - setCanvas({ title, markdown: content, isHtml }) - } + onOpenArtifact={handleOpenArtifact} onRetry={retryFn} /> ); diff --git a/frontend/src/pages/Collect.tsx b/frontend/src/pages/Collect.tsx index dd79e26..519e672 100644 --- a/frontend/src/pages/Collect.tsx +++ b/frontend/src/pages/Collect.tsx @@ -2,7 +2,7 @@ * 论文收集与订阅管理(重构版:手动抓取 + 丰富结果展示) * @author Color2333 */ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, memo } from "react"; import { useNavigate } from "react-router-dom"; import { Button, Empty, Spinner } from "@/components/ui"; import { @@ -162,6 +162,13 @@ export default function Collect() { } }, [multiSuggestions]); + // 稳定 callback:避免 SearchResultCard memo 被内联箭头击穿(每项 expanded 切换只重渲染该项) + const handleResultToggle = useCallback((index: number) => { + setResults((prev) => + prev.map((x, j) => (j === index ? { ...x, expanded: !x.expanded } : x)) + ); + }, []); + // ========== 订阅管理 ========== const [topics, setTopics] = useState([]); const [loading, setLoading] = useState(true); @@ -577,12 +584,9 @@ export default function Collect() { - setResults((prev) => - prev.map((x, j) => (j === i ? { ...x, expanded: !x.expanded } : x)) - ) - } - onNavigate={(paperId) => navigate(`/papers/${paperId}`)} + index={i} + onToggle={handleResultToggle} + onNavigate={navigate} /> ))}
@@ -1121,19 +1125,21 @@ function TopicCard({ /* ================================================================ * 即时搜索结果卡片 * ================================================================ */ -function SearchResultCard({ +const SearchResultCard = memo(function SearchResultCard({ result: r, + index, onToggle, onNavigate, }: { result: SearchResult; - onToggle: () => void; - onNavigate: (id: string) => void; + index: number; + onToggle: (index: number) => void; + onNavigate: (path: string) => void; }) { return (
{/* 头部:摘要信息 */} -
); -} +}); /* ================================================================ * 通用表单字段 diff --git a/frontend/src/pages/Papers.tsx b/frontend/src/pages/Papers.tsx index 503914a..32e677d 100644 --- a/frontend/src/pages/Papers.tsx +++ b/frontend/src/pages/Papers.tsx @@ -1111,10 +1111,10 @@ export default function Papers() { key={paper.id} paper={paper} selected={selected.has(paper.id)} - onSelect={() => toggleSelect(paper.id)} - onFavorite={(e) => handleToggleFavorite(e, paper.id)} - onReject={(e) => handleToggleRejected(e, paper.id)} - onClick={() => navigate(`/papers/${paper.id}`)} + onSelect={toggleSelect} + onFavorite={handleToggleFavorite} + onReject={handleToggleRejected} + onNavigate={navigate} /> ))} @@ -1124,9 +1124,9 @@ export default function Papers() { handleToggleFavorite(e, paper.id)} - onReject={(e) => handleToggleRejected(e, paper.id)} - onClick={() => navigate(`/papers/${paper.id}`)} + onFavorite={handleToggleFavorite} + onReject={handleToggleRejected} + onNavigate={navigate} /> ))} @@ -1255,14 +1255,14 @@ const PaperListItem = memo(function PaperListItem({ onSelect, onFavorite, onReject, - onClick, + onNavigate, }: { paper: Paper; selected: boolean; - onSelect: () => void; - onFavorite: (e: React.MouseEvent) => void; - onReject: (e: React.MouseEvent) => void; - onClick: () => void; + onSelect: (id: string) => void; + onFavorite: (e: React.MouseEvent, id: string) => void; + onReject: (e: React.MouseEvent, id: string) => void; + onNavigate: (path: string) => void; }) { const sc = statusBadge[paper.read_status] || statusBadge.unread; return ( @@ -1275,11 +1275,11 @@ const PaperListItem = memo(function PaperListItem({ onSelect(paper.id)} onClick={(e) => e.stopPropagation()} className="border-border text-primary focus:ring-primary/30 mt-1 h-3.5 w-3.5 shrink-0 rounded" /> - + ) : ( + + )} + + + {run.decision_note ? ( + {run.decision_note} + ) : run.error_message ? ( + {run.error_message} + ) : ( + + )} + + + {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : "—"} + + {timeAgo(run.created_at)} + + ); +}); + const STATUS_FILTERS = [ { key: "all", label: "全部" }, { key: "succeeded", label: "成功" }, @@ -144,41 +181,7 @@ export default function Pipelines() { {filtered.map((run) => ( - - - - - {run.pipeline_name} - - {run.paper_id ? ( - - ) : ( - - )} - - - {run.decision_note ? ( - - {run.decision_note} - - ) : run.error_message ? ( - {run.error_message} - ) : ( - - )} - - - {run.elapsed_ms != null ? formatDuration(run.elapsed_ms) : "—"} - - - {timeAgo(run.created_at)} - - + ))} diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx index aa1d7d1..188b7e7 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -2,7 +2,7 @@ * Statistics - 主题统计分析 * @author Color2333 */ -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, memo } from "react"; import { topicApi } from "@/services/api"; import type { TopicStats, TopicStatsResponse, PaperDistributionResponse } from "@/types"; import { @@ -99,7 +99,7 @@ function StatCard({ ); } -function TopicCard({ stat }: { stat: TopicStats }) { +const TopicCard = memo(function TopicCard({ stat }: { stat: TopicStats }) { const total = stat.status_dist.unread + stat.status_dist.skimmed + stat.status_dist.deep_read; const readRate = total > 0 @@ -176,7 +176,7 @@ function TopicCard({ stat }: { stat: TopicStats }) { ); -} +}); function CitationBar({ stat, max, index }: { stat: TopicStats; max: number; index: number }) { const colors = [