Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions frontend/src/contexts/ChannelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -79,19 +79,21 @@ export function ChannelProvider({ children }: { children: ReactNode }) {
setDefaultChannels(ids);
}, []);

// value useMemo:各 callback 已稳定,仅依赖数据变化时重建 value,避免每次 Provider render
// 新建 value 对象导致所有 useChannels 消费者重渲染
const value = useMemo<ChannelContextValue>(() => ({
channels,
defaultChannels,
loading,
error,
getChannel,
updateChannelStatus,
setDefaultChannels: setDefault,
refreshChannels: fetchChannels,
}), [channels, defaultChannels, loading, error, getChannel, updateChannelStatus, setDefault, fetchChannels]);

return (
<ChannelContext.Provider
value={{
channels,
defaultChannels,
loading,
error,
getChannel,
updateChannelStatus,
setDefaultChannels: setDefault,
refreshChannels: fetchChannels,
}}
>
<ChannelContext.Provider value={value}>
{children}
</ChannelContext.Provider>
);
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 (
<Ctx.Provider value={{ toasts, toast, dismiss }}>
<Ctx.Provider value={value}>
{children}
</Ctx.Provider>
);
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/pages/Agent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -261,7 +268,7 @@ export default function Agent() {
className="relative flex-1 overflow-y-auto"
>
{items.length === 0 ? (
<EmptyState onSelect={(p) => handleSend(p)} />
<EmptyState onSelect={handleSend} />
) : (
<div className="mx-auto max-w-3xl px-4 py-6">
{items.map((item, idx) => {
Expand All @@ -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}
/>
);
Expand Down
32 changes: 19 additions & 13 deletions frontend/src/pages/Collect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Topic[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -577,12 +584,9 @@ export default function Collect() {
<SearchResultCard
key={`result-${r.query}-${i}`}
result={r}
onToggle={() =>
setResults((prev) =>
prev.map((x, j) => (j === i ? { ...x, expanded: !x.expanded } : x))
)
}
onNavigate={(paperId) => navigate(`/papers/${paperId}`)}
index={i}
onToggle={handleResultToggle}
onNavigate={navigate}
/>
))}
</div>
Expand Down Expand Up @@ -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 (
<div className="border-success/20 bg-success/[0.03] rounded-xl border transition-all">
{/* 头部:摘要信息 */}
<button onClick={onToggle} className="flex w-full items-center gap-3 px-4 py-3 text-left">
<button onClick={() => onToggle(index)} className="flex w-full items-center gap-3 px-4 py-3 text-left">
<CheckCircle2 className="text-success h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -1178,7 +1184,7 @@ function SearchResultCard({
</div>
</div>
<button
onClick={() => onNavigate(p.id)}
onClick={() => onNavigate(`/papers/${p.id}`)}
className="text-ink-tertiary hover:bg-primary/10 hover:text-primary shrink-0 rounded-md p-1 transition-colors"
title="查看论文"
>
Expand All @@ -1191,7 +1197,7 @@ function SearchResultCard({
)}
</div>
);
}
});

/* ================================================================
* 通用表单字段
Expand Down
49 changes: 25 additions & 24 deletions frontend/src/pages/Papers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
))}
</div>
Expand All @@ -1124,9 +1124,9 @@ export default function Papers() {
<PaperGridItem
key={paper.id}
paper={paper}
onFavorite={(e) => handleToggleFavorite(e, paper.id)}
onReject={(e) => handleToggleRejected(e, paper.id)}
onClick={() => navigate(`/papers/${paper.id}`)}
onFavorite={handleToggleFavorite}
onReject={handleToggleRejected}
onNavigate={navigate}
/>
))}
</div>
Expand Down Expand Up @@ -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 (
Expand All @@ -1275,11 +1275,11 @@ const PaperListItem = memo(function PaperListItem({
<input
type="checkbox"
checked={selected}
onChange={onSelect}
onChange={() => 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"
/>
<button className="flex min-w-0 flex-1 items-start gap-2.5 text-left" onClick={onClick}>
<button className="flex min-w-0 flex-1 items-start gap-2.5 text-left" onClick={() => onNavigate(`/papers/${paper.id}`)}>
{/* 状态图标 */}
<div
className={`mt-0.5 shrink-0 rounded-lg p-1.5 ${
Expand Down Expand Up @@ -1367,7 +1367,7 @@ const PaperListItem = memo(function PaperListItem({
</button>
<button
aria-label={paper.favorited ? "取消收藏" : "收藏"}
onClick={onFavorite}
onClick={(e) => onFavorite(e, paper.id)}
className="hover:bg-error/10 mt-0.5 shrink-0 rounded-lg p-1 transition-colors"
>
<Heart
Expand All @@ -1376,7 +1376,7 @@ const PaperListItem = memo(function PaperListItem({
</button>
<button
aria-label={paper.rejected ? "取消不感兴趣" : "不感兴趣"}
onClick={onReject}
onClick={(e) => onReject(e, paper.id)}
className="hover:bg-error/10 mt-0.5 shrink-0 rounded-lg p-1 transition-colors"
>
<Ban
Expand All @@ -1393,27 +1393,28 @@ const PaperGridItem = memo(function PaperGridItem({
paper,
onFavorite,
onReject,
onClick,
onNavigate,
}: {
paper: Paper;
onFavorite: (e: React.MouseEvent) => void;
onReject: (e: React.MouseEvent) => void;
onClick: () => 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;
const go = () => onNavigate(`/papers/${paper.id}`);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => e.key === "Enter" && onClick()}
onClick={go}
onKeyDown={(e) => e.key === "Enter" && go()}
className="group border-border/60 bg-surface flex cursor-pointer flex-col rounded-xl border p-3.5 text-left transition-all hover:shadow-sm"
>
<div className="mb-2 flex items-center justify-between">
<Badge variant={sc.variant}>{sc.label}</Badge>
<button
aria-label={paper.favorited ? "取消收藏" : "收藏"}
onClick={onFavorite}
onClick={(e) => onFavorite(e, paper.id)}
className="hover:bg-error/10 rounded-lg p-1 transition-colors"
>
<Heart
Expand All @@ -1422,7 +1423,7 @@ const PaperGridItem = memo(function PaperGridItem({
</button>
<button
aria-label={paper.rejected ? "取消不感兴趣" : "不感兴趣"}
onClick={onReject}
onClick={(e) => onReject(e, paper.id)}
className="hover:bg-error/10 rounded-lg p-1 transition-colors"
>
<Ban
Expand Down
Loading