refactor(web): React 最佳实践优化 — memo/callback + 目录边界 + 路由增强

- useLogger: useMemo + JSON.stringify 替代 useState 派生
- useIsDark: effectiveTheme 替代 token 色值比较
- useCurrentProject: layouts/ 提升到 shared/hooks/
- ConsoleShell: locale useMemo 缓存
- ConsoleOutlet: 添加 Suspense 边界
- routes: 添加 layout 级 errorElement
- Table 组件: operationColumn useMemo + useCallback
- ChatPanel: footer 合并为 useCallback, props 传入模型数据
- ChatPage: textModels/conversations useMemo 缓存
This commit is contained in:
2026-06-03 11:32:28 +08:00
parent 297293cb61
commit 5b09a16bc3
18 changed files with 342 additions and 245 deletions

View File

@@ -2,12 +2,12 @@ import { DeleteOutlined, MoreOutlined } from "@ant-design/icons";
import { Conversations } from "@ant-design/x";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Spin } from "antd";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { Conversation } from "../../../shared/api";
import { useCurrentProject } from "../../layouts/workbench-layout/useCurrentProject";
import { createConversation, deleteConversation, fetchConversations } from "../../shared/hooks/use-conversations";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { useModelList } from "../../shared/hooks/use-models";
import { ChatPanel } from "./ChatPanel";
@@ -25,8 +25,13 @@ export function ChatPage() {
});
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text"));
const defaultModelId = textModels[0]?.id;
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const defaultModelId = textModels[0]?.id ?? null;
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteConversation(project.id, id),
@@ -39,10 +44,10 @@ export function ChatPage() {
},
});
const conversations = (data?.items ?? []).map((c: Conversation) => ({
key: c.id,
label: c.title,
}));
const conversations = useMemo(
() => (data?.items ?? []).map((c: Conversation) => ({ key: c.id, label: c.title })),
[data],
);
return (
<div className="app-chat-page">
@@ -54,7 +59,7 @@ export function ChatPage() {
activeKey={activeConversationId ?? ""}
creation={{
onClick: () => {
void createConversation(project.id, defaultModelId)
void createConversation(project.id, defaultModelId ?? undefined)
.then((conv) => {
void queryClient.invalidateQueries({ queryKey: CONVERSATIONS_KEY });
setActiveConversationId(conv.id);
@@ -85,8 +90,10 @@ export function ChatPage() {
</div>
<ChatPanel
conversationId={activeConversationId}
defaultModelId={defaultModelId}
onConversationCreated={setActiveConversationId}
projectId={project.id}
textModels={textModels}
/>
</div>
);

View File

@@ -4,7 +4,7 @@ import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Divider, Flex, Input, Select, Spin, Typography } from "antd";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createConversation,
@@ -13,7 +13,6 @@ import {
updateConversation,
} from "../../shared/hooks/use-conversations";
import { useLogger } from "../../shared/hooks/use-logger";
import { useModelList } from "../../shared/hooks/use-models";
import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart";
@@ -21,11 +20,19 @@ import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps {
conversationId: null | string;
defaultModelId: null | string;
onConversationCreated: (id: string) => void;
projectId: string;
textModels: Array<{ id: string; name: string }>;
}
export function ChatPanel({ conversationId, onConversationCreated, projectId }: ChatPanelProps) {
export function ChatPanel({
conversationId,
defaultModelId: _defaultModelId,
onConversationCreated,
projectId,
textModels,
}: ChatPanelProps) {
const { message } = App.useApp();
const logger = useLogger({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient();
@@ -37,12 +44,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const fetchRef = useRef(fetchMessages);
const skipHistoryLoadRef = useRef<null | string>(null);
const { data: modelsData } = useModelList({ pageSize: 200 });
const textModels = useMemo(
() => (modelsData?.items ?? []).filter((m) => m.capabilities.includes("text")),
[modelsData],
);
const modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({
@@ -178,6 +179,33 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
[sendMessage, conversationId, projectId, onConversationCreated, message, queryClient, displayModelId, logger],
);
const renderSenderFooter = useCallback(
(actionNode: ReactNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
),
[isLoading, handleModelChange, modelOptions, displayModelId],
);
const handleStop = useCallback(() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}, [stop, logger]);
const extractText = useCallback((msg: UIMessage) => {
return msg.parts
.filter((p) => p.type === "text")
@@ -304,29 +332,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
footer={renderSenderFooter}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."
@@ -397,29 +405,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender
autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => (
<Flex align="center" justify="space-between">
<Select
className="chat-model-select"
disabled={isLoading}
onChange={handleModelChange}
options={modelOptions}
placeholder="选择模型"
value={displayModelId}
/>
<Flex align="center">
<Divider orientation="vertical" />
{actionNode}
</Flex>
</Flex>
)}
footer={renderSenderFooter}
loading={isLoading}
onCancel={() => {
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onCancel={handleStop}
onChange={setInput}
onSubmit={handleSenderSubmit}
placeholder="输入消息..."

View File

@@ -1,7 +1,8 @@
import type { ColumnsType } from "antd/es/table";
import type { TableColumnsType } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react";
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api";
@@ -31,7 +32,7 @@ function getProviderName(providerId: string, providers: ProviderOption[]): strin
return providers.find((p) => p.id === providerId)?.name ?? providerId;
}
const COLUMNS: ColumnsType<Model> = [
const COLUMNS: TableColumnsType<Model> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{
dataIndex: "providerId",
@@ -65,49 +66,61 @@ export function ModelTable({
}: ModelTableProps) {
const { message } = AntApp.useApp();
const handleDelete = async (id: string) => {
try {
await onDelete(id);
message.success("模型已删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
};
const columnsWithProvider: ColumnsType<Model> = COLUMNS.map((col) =>
"dataIndex" in col && col.dataIndex === "providerId"
? {
...col,
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
}
: col,
const handleDelete = useCallback(
async (id: string) => {
try {
await onDelete(id);
message.success("模型已删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[onDelete, message],
);
const operationColumn: ColumnsType<Model>[number] = {
dataIndex: "op",
render: (_value: unknown, record: Model) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认删除此模型?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
const columnsWithProvider = useMemo<TableColumnsType<Model>>(
() =>
COLUMNS.map((col) =>
"dataIndex" in col && col.dataIndex === "providerId"
? {
...col,
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
}
: col,
),
[providers],
);
const operationColumn = useMemo<TableColumnsType<Model>[number]>(
() => ({
dataIndex: "op",
render: (_value: unknown, record: Model) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
};
<Popconfirm
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认删除此模型?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
}),
[onEdit, handleDelete],
);
const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]);
return (
<Table
columns={[...columnsWithProvider, operationColumn]}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={{

View File

@@ -1,7 +1,8 @@
import type { ColumnsType } from "antd/es/table";
import type { TableColumnsType } from "antd";
import { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table } from "antd";
import { useCallback, useMemo } from "react";
import type { Provider, ProviderListResponse } from "../../../../shared/api";
@@ -21,7 +22,7 @@ const TYPE_LABELS: Record<Provider["type"], string> = {
"openai-compatible": "OpenAI 兼容",
};
const COLUMNS: ColumnsType<Provider> = [
const COLUMNS: TableColumnsType<Provider> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{
dataIndex: "type",
@@ -35,40 +36,48 @@ const COLUMNS: ColumnsType<Provider> = [
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
const { message } = AntApp.useApp();
const handleDelete = async (id: string) => {
try {
await onDelete(id);
message.success("供应商已删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
};
const handleDelete = useCallback(
async (id: string) => {
try {
await onDelete(id);
message.success("供应商已删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[onDelete, message],
);
const operationColumn: ColumnsType<Provider>[number] = {
dataIndex: "op",
render: (_value: unknown, record: Provider) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm
description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。"
onConfirm={() => void handleDelete(record.id)}
title="确认删除此供应商?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
const operationColumn = useMemo<TableColumnsType<Provider>[number]>(
() => ({
dataIndex: "op",
render: (_value: unknown, record: Provider) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
};
<Popconfirm
description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。"
onConfirm={() => void handleDelete(record.id)}
title="确认删除此供应商?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
}),
[onEdit, handleDelete],
);
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
return (
<Table
columns={[...COLUMNS, operationColumn]}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={{

View File

@@ -1,7 +1,8 @@
import type { ColumnsType } from "antd/es/table";
import type { TableColumnsType } from "antd";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router";
import type { Project, ProjectListResponse, ProjectStatus } from "../../../../shared/api";
@@ -19,7 +20,7 @@ interface ProjectTableProps {
status: ProjectStatus;
}
const COLUMNS: ColumnsType<Project> = [
const COLUMNS: TableColumnsType<Project> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
{ dataIndex: "description", ellipsis: true, title: "描述" },
{
@@ -65,88 +66,102 @@ export function ProjectTable({
const { message } = AntApp.useApp();
const navigate = useNavigate();
const handleArchive = async (id: string) => {
try {
await onArchive(id);
message.success("项目已归档");
} catch (err: unknown) {
message.error((err as Error).message);
}
};
const handleArchive = useCallback(
async (id: string) => {
try {
await onArchive(id);
message.success("项目已归档");
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[onArchive, message],
);
const handleRestore = async (id: string) => {
try {
await onRestore(id);
message.success("项目已恢复");
} catch (err: unknown) {
message.error((err as Error).message);
}
};
const handleRestore = useCallback(
async (id: string) => {
try {
await onRestore(id);
message.success("项目已恢复");
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[onRestore, message],
);
const handleDelete = async (id: string) => {
try {
await onDelete(id);
message.success("项目已永久删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
};
const handleDelete = useCallback(
async (id: string) => {
try {
await onDelete(id);
message.success("项目已永久删除");
} catch (err: unknown) {
message.error((err as Error).message);
}
},
[onDelete, message],
);
const operationColumn: ColumnsType<Project>[number] = {
dataIndex: "op",
render: (_value, record: Project) => {
if (record.status === "active") {
const operationColumn = useMemo<TableColumnsType<Project>[number]>(
() => ({
dataIndex: "op",
render: (_value, record: Project) => {
if (record.status === "active") {
return (
<Space size="small">
<Button
icon={<LoginOutlined />}
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small"
type="link"
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm
description="归档后项目将变为只读。"
onConfirm={() => void handleArchive(record.id)}
title="确认归档此项目?"
>
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
</Button>
</Popconfirm>
</Space>
);
}
return (
<Space size="small">
<Button
icon={<LoginOutlined />}
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small"
type="link"
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
<Button icon={<RedoOutlined />} size="small" type="link">
</Button>
</Popconfirm>
<Popconfirm
description="归档后项目将变为只读。"
onConfirm={() => void handleArchive(record.id)}
title="确认归档此项目?"
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认永久删除此项目?"
>
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link">
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
);
}
return (
<Space size="small">
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
<Button icon={<RedoOutlined />} size="small" type="link">
</Button>
</Popconfirm>
<Popconfirm
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认永久删除此项目?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
);
},
title: "操作",
width: status === "active" ? 260 : 160,
};
},
title: "操作",
width: status === "active" ? 260 : 160,
}),
[navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
);
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
return (
<Table
columns={[...COLUMNS, operationColumn]}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={{