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

@@ -52,17 +52,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
### 共享 Hooks ### 共享 Hooks
| Hook | 路径 | 说明 | | Hook | 路径 | 说明 |
| ----------------------- | --------------------------------------- | ----------------------------------------- | | ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
| `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`30s 轮询5s staleTime | | `use-meta.ts` | `shared/hooks/use-meta.ts` | `/api/meta`30s 轮询5s staleTime |
| `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection | | `use-providers.ts` | `shared/hooks/use-providers.ts` | 供应商 CRUD + test connection |
| `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection | | `use-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore | | `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks | | `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 | | `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 |
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 | | `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 | | `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 | | `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
### 共享工具函数 ### 共享工具函数

View File

@@ -11,27 +11,51 @@
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/ant-design/SKILL.md", "skillPath": "skills/ant-design/SKILL.md",
"computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179" "computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466"
}, },
"antd": { "antd": {
"source": "ant-design/antd-skill", "source": "ant-design/antd-skill",
"sourceType": "github", "sourceType": "github",
"skillPath": "skills/antd/SKILL.md", "skillPath": "skills/antd/SKILL.md",
"computedHash": "4295010f09f85855cab9e9de9ec7f96c14541474b4f3f9d6ef89006430931b94" "computedHash": "5e26c8042060bb811118927b5daf637af7929a00fa973dd8f5f804f3ba6e2bf2"
},
"react-router-data-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-data-mode/SKILL.md",
"computedHash": "76e3e0f70ff47b743bd90999e676515221e25fd7ee89cd9e5b340417b1a601e2"
},
"react-router-declarative-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-declarative-mode/SKILL.md",
"computedHash": "d7ebbf1ede90809618f02cb3b3d37b9871cdd6c88a81cf338e63de50a0df6a42"
},
"react-router-framework-mode": {
"source": "remix-run/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-router-framework-mode/SKILL.md",
"computedHash": "26c5bdac2f686c47eb4c4b48b6cb52401cde1dc833e6d26408ddfb22ea83c5ca"
},
"vercel-react-best-practices": {
"source": "vercel-labs/agent-skills",
"sourceType": "github",
"skillPath": "skills/react-best-practices/SKILL.md",
"computedHash": "ca7b0c0c6e5f2750043f7f0cd72d16ac4e2abc48f9b5500d047a4b77a2506212"
}, },
"x-components": { "x-components": {
"source": "ant-design/x", "source": "ant-design/x",
"ref": "main", "ref": "main",
"sourceType": "github", "sourceType": "github",
"skillPath": "packages/x-skill/skills/x-components/SKILL.md", "skillPath": "packages/x-skill/skills/x-components/SKILL.md",
"computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2" "computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
}, },
"x-markdown": { "x-markdown": {
"source": "ant-design/x", "source": "ant-design/x",
"ref": "main", "ref": "main",
"sourceType": "github", "sourceType": "github",
"skillPath": "packages/x-skill/skills/x-markdown/SKILL.md", "skillPath": "packages/x-skill/skills/x-markdown/SKILL.md",
"computedHash": "2d26b8eda1692929e99a8b6163ef8b206f1f096a4a84507b50dbe836a7ec041e" "computedHash": "441c281e8537e4aebbc6db5dce0b12c170df916f81782f33f3c8f66dd3f17b17"
} }
} }
} }

View File

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

View File

@@ -4,7 +4,7 @@ import { Sender } from "@ant-design/x";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { DefaultChatTransport, type UIMessage } from "ai"; import { DefaultChatTransport, type UIMessage } from "ai";
import { App, Button, Card, Divider, Flex, Input, Select, Spin, Typography } from "antd"; 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 { import {
createConversation, createConversation,
@@ -13,7 +13,6 @@ import {
updateConversation, updateConversation,
} from "../../shared/hooks/use-conversations"; } from "../../shared/hooks/use-conversations";
import { useLogger } from "../../shared/hooks/use-logger"; import { useLogger } from "../../shared/hooks/use-logger";
import { useModelList } from "../../shared/hooks/use-models";
import { ChatScrollArea } from "./ChatScrollArea"; import { ChatScrollArea } from "./ChatScrollArea";
import { ReasoningPart } from "./parts/ReasoningPart"; import { ReasoningPart } from "./parts/ReasoningPart";
import { TextPart } from "./parts/TextPart"; import { TextPart } from "./parts/TextPart";
@@ -21,11 +20,19 @@ import { ToolPart } from "./parts/ToolPart";
interface ChatPanelProps { interface ChatPanelProps {
conversationId: null | string; conversationId: null | string;
defaultModelId: null | string;
onConversationCreated: (id: string) => void; onConversationCreated: (id: string) => void;
projectId: string; 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 { message } = App.useApp();
const logger = useLogger({ component: "ChatPanel", page: "workbench" }); const logger = useLogger({ component: "ChatPanel", page: "workbench" });
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -37,12 +44,6 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
const fetchRef = useRef(fetchMessages); const fetchRef = useRef(fetchMessages);
const skipHistoryLoadRef = useRef<null | string>(null); 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 modelOptions = useMemo(() => textModels.map((m) => ({ label: m.name, value: m.id })), [textModels]);
const { messages, regenerate, sendMessage, setMessages, status, stop } = useChat({ 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], [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) => { const extractText = useCallback((msg: UIMessage) => {
return msg.parts return msg.parts
.filter((p) => p.type === "text") .filter((p) => p.type === "text")
@@ -304,29 +332,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender <Sender
autoSize={{ maxRows: 3, minRows: 1 }} autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }} classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => ( footer={renderSenderFooter}
<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>
)}
loading={isLoading} loading={isLoading}
onCancel={() => { onCancel={handleStop}
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onChange={setInput} onChange={setInput}
onSubmit={handleSenderSubmit} onSubmit={handleSenderSubmit}
placeholder="输入消息..." placeholder="输入消息..."
@@ -397,29 +405,9 @@ export function ChatPanel({ conversationId, onConversationCreated, projectId }:
<Sender <Sender
autoSize={{ maxRows: 3, minRows: 1 }} autoSize={{ maxRows: 3, minRows: 1 }}
classNames={{ root: "chat-sender-box" }} classNames={{ root: "chat-sender-box" }}
footer={(actionNode) => ( footer={renderSenderFooter}
<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>
)}
loading={isLoading} loading={isLoading}
onCancel={() => { onCancel={handleStop}
void stop().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
logger.warn("停止聊天失败", { error: msg });
});
}}
onChange={setInput} onChange={setInput}
onSubmit={handleSenderSubmit} onSubmit={handleSenderSubmit}
placeholder="输入消息..." 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 { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd"; import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react";
import type { Model, ModelListResponse, ProviderOption } from "../../../../shared/api"; 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; 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: "name", ellipsis: true, title: "名称", width: 180 },
{ {
dataIndex: "providerId", dataIndex: "providerId",
@@ -65,49 +66,61 @@ export function ModelTable({
}: ModelTableProps) { }: ModelTableProps) {
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const handleDelete = async (id: string) => { const handleDelete = useCallback(
try { async (id: string) => {
await onDelete(id); try {
message.success("模型已删除"); await onDelete(id);
} catch (err: unknown) { message.success("模型已删除");
message.error((err as Error).message); } catch (err: unknown) {
} message.error((err as Error).message);
}; }
},
const columnsWithProvider: ColumnsType<Model> = COLUMNS.map((col) => [onDelete, message],
"dataIndex" in col && col.dataIndex === "providerId"
? {
...col,
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
}
: col,
); );
const operationColumn: ColumnsType<Model>[number] = { const columnsWithProvider = useMemo<TableColumnsType<Model>>(
dataIndex: "op", () =>
render: (_value: unknown, record: Model) => ( COLUMNS.map((col) =>
<Space size="small"> "dataIndex" in col && col.dataIndex === "providerId"
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link"> ? {
...col,
</Button> render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
<Popconfirm }
description="此操作不可恢复。" : col,
onConfirm={() => void handleDelete(record.id)} ),
title="确认删除此模型?" [providers],
> );
<Button danger icon={<DeleteOutlined />} size="small" type="link">
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> </Button>
</Popconfirm> <Popconfirm
</Space> description="此操作不可恢复。"
), onConfirm={() => void handleDelete(record.id)}
title: "操作", title="确认删除此模型?"
width: 180, >
}; <Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
}),
[onEdit, handleDelete],
);
const columns = useMemo(() => [...columnsWithProvider, operationColumn], [columnsWithProvider, operationColumn]);
return ( return (
<Table <Table
columns={[...columnsWithProvider, operationColumn]} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
pagination={{ 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 { DeleteOutlined, EditOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table } from "antd"; import { App as AntApp, Button, Popconfirm, Space, Table } from "antd";
import { useCallback, useMemo } from "react";
import type { Provider, ProviderListResponse } from "../../../../shared/api"; import type { Provider, ProviderListResponse } from "../../../../shared/api";
@@ -21,7 +22,7 @@ const TYPE_LABELS: Record<Provider["type"], string> = {
"openai-compatible": "OpenAI 兼容", "openai-compatible": "OpenAI 兼容",
}; };
const COLUMNS: ColumnsType<Provider> = [ const COLUMNS: TableColumnsType<Provider> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 180 }, { dataIndex: "name", ellipsis: true, title: "名称", width: 180 },
{ {
dataIndex: "type", dataIndex: "type",
@@ -35,40 +36,48 @@ const COLUMNS: ColumnsType<Provider> = [
export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) { export function ProviderTable({ data, loading, onDelete, onEdit, onPageChange, page, pageSize }: ProviderTableProps) {
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const handleDelete = async (id: string) => { const handleDelete = useCallback(
try { async (id: string) => {
await onDelete(id); try {
message.success("供应商已删除"); await onDelete(id);
} catch (err: unknown) { message.success("供应商已删除");
message.error((err as Error).message); } catch (err: unknown) {
} message.error((err as Error).message);
}; }
},
[onDelete, message],
);
const operationColumn: ColumnsType<Provider>[number] = { const operationColumn = useMemo<TableColumnsType<Provider>[number]>(
dataIndex: "op", () => ({
render: (_value: unknown, record: Provider) => ( dataIndex: "op",
<Space size="small"> render: (_value: unknown, record: Provider) => (
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link"> <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">
</Button> </Button>
</Popconfirm> <Popconfirm
</Space> description="该供应商下存在模型时无法删除,请先删除或迁移相关模型。"
), onConfirm={() => void handleDelete(record.id)}
title: "操作", title="确认删除此供应商?"
width: 180, >
}; <Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 180,
}),
[onEdit, handleDelete],
);
const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
return ( return (
<Table <Table
columns={[...COLUMNS, operationColumn]} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
pagination={{ 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 { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd"; import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { Project, ProjectListResponse, ProjectStatus } from "../../../../shared/api"; import type { Project, ProjectListResponse, ProjectStatus } from "../../../../shared/api";
@@ -19,7 +20,7 @@ interface ProjectTableProps {
status: ProjectStatus; status: ProjectStatus;
} }
const COLUMNS: ColumnsType<Project> = [ const COLUMNS: TableColumnsType<Project> = [
{ dataIndex: "name", ellipsis: true, title: "名称", width: 140 }, { dataIndex: "name", ellipsis: true, title: "名称", width: 140 },
{ dataIndex: "description", ellipsis: true, title: "描述" }, { dataIndex: "description", ellipsis: true, title: "描述" },
{ {
@@ -65,88 +66,102 @@ export function ProjectTable({
const { message } = AntApp.useApp(); const { message } = AntApp.useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const handleArchive = async (id: string) => { const handleArchive = useCallback(
try { async (id: string) => {
await onArchive(id); try {
message.success("项目已归档"); await onArchive(id);
} catch (err: unknown) { message.success("项目已归档");
message.error((err as Error).message); } catch (err: unknown) {
} message.error((err as Error).message);
}; }
},
[onArchive, message],
);
const handleRestore = async (id: string) => { const handleRestore = useCallback(
try { async (id: string) => {
await onRestore(id); try {
message.success("项目已恢复"); await onRestore(id);
} catch (err: unknown) { message.success("项目已恢复");
message.error((err as Error).message); } catch (err: unknown) {
} message.error((err as Error).message);
}; }
},
[onRestore, message],
);
const handleDelete = async (id: string) => { const handleDelete = useCallback(
try { async (id: string) => {
await onDelete(id); try {
message.success("项目已永久删除"); await onDelete(id);
} catch (err: unknown) { message.success("项目已永久删除");
message.error((err as Error).message); } catch (err: unknown) {
} message.error((err as Error).message);
}; }
},
[onDelete, message],
);
const operationColumn: ColumnsType<Project>[number] = { const operationColumn = useMemo<TableColumnsType<Project>[number]>(
dataIndex: "op", () => ({
render: (_value, record: Project) => { dataIndex: "op",
if (record.status === "active") { 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 ( return (
<Space size="small"> <Space size="small">
<Button <Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?">
icon={<LoginOutlined />} <Button icon={<RedoOutlined />} size="small" type="link">
onClick={() => void navigate(`/workbench/${record.id}`)}
size="small" </Button>
type="link" </Popconfirm>
>
</Button>
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
<Popconfirm <Popconfirm
description="归档后项目将变为只读。" description="此操作不可恢复。"
onConfirm={() => void handleArchive(record.id)} onConfirm={() => void handleDelete(record.id)}
title="确认归档此项目?" title="确认永久删除此项目?"
> >
<Button color="orange" icon={<InboxOutlined />} size="small" variant="link"> <Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button> </Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
); );
} },
return ( title: "操作",
<Space size="small"> width: status === "active" ? 260 : 160,
<Popconfirm onConfirm={() => void handleRestore(record.id)} title="确认恢复此项目?"> }),
<Button icon={<RedoOutlined />} size="small" type="link"> [navigate, onEdit, handleArchive, handleRestore, handleDelete, status],
);
</Button>
</Popconfirm> const columns = useMemo(() => [...COLUMNS, operationColumn], [operationColumn]);
<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,
};
return ( return (
<Table <Table
columns={[...COLUMNS, operationColumn]} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
loading={loading} loading={loading}
pagination={{ pagination={{

View File

@@ -2,7 +2,7 @@ import { type ReactNode } from "react";
import type { Project } from "../../../shared/api"; import type { Project } from "../../../shared/api";
import { ProjectContext } from "./ProjectContextValue"; import { ProjectContext } from "../../shared/hooks/use-current-project";
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) { export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>; return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;

View File

@@ -1,5 +0,0 @@
import { createContext } from "react";
import type { Project } from "../../../shared/api";
export const ProjectContext = createContext<null | Project>(null);

View File

@@ -5,9 +5,9 @@ import { useNavigate } from "react-router";
import type { Project } from "../../../shared/api"; import type { Project } from "../../../shared/api";
import { ConsoleShell } from "../../shared/components/ConsoleShell/ConsoleShell"; import { ConsoleShell } from "../../shared/components/ConsoleShell/ConsoleShell";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { ProjectProvider } from "./ProjectContext"; import { ProjectProvider } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes"; import { getWorkbenchMenuItems } from "./routes";
import { useCurrentProject } from "./useCurrentProject";
interface WorkbenchConsoleLayoutProps { interface WorkbenchConsoleLayoutProps {
project: Project; project: Project;

View File

@@ -8,16 +8,17 @@ import { NotFoundPage } from "./features/not-found";
import { ProjectsPage } from "./features/projects"; import { ProjectsPage } from "./features/projects";
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout"; import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate"; import { WorkbenchProjectGate } from "./layouts/workbench-layout/WorkbenchProjectGate";
import { RouteError } from "./shared/components/RouteError";
export function AppRoutes() { export function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route element={<AdminConsoleLayout />}> <Route element={<AdminConsoleLayout />} errorElement={<RouteError />}>
<Route element={<DashboardPage />} path="/" /> <Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" /> <Route element={<ProjectsPage />} path="/projects" />
<Route element={<ModelsPage />} path="/models" /> <Route element={<ModelsPage />} path="/models" />
</Route> </Route>
<Route element={<WorkbenchProjectGate />} path="/workbench/:projectId"> <Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
<Route element={<ChatPage />} path="" /> <Route element={<ChatPage />} path="" />
<Route element={<ChatPage />} path="chat" /> <Route element={<ChatPage />} path="chat" />
<Route element={<InboxPage />} path="inbox" /> <Route element={<InboxPage />} path="inbox" />

View File

@@ -1,5 +1,17 @@
import { Spin } from "antd";
import { Suspense } from "react";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
export function ConsoleOutlet() { export function ConsoleOutlet() {
return <Outlet />; return (
<Suspense
fallback={
<div className="app-loading">
<Spin size="large" />
</div>
}
>
<Outlet />
</Suspense>
);
} }

View File

@@ -3,6 +3,7 @@ import { XProvider } from "@ant-design/x";
import zhCN_X from "@ant-design/x/locale/zh_CN"; import zhCN_X from "@ant-design/x/locale/zh_CN";
import { App as AntApp, Layout, Segmented, theme } from "antd"; import { App as AntApp, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
import { useMemo } from "react";
import type { ConsoleShellProps } from "./types"; import type { ConsoleShellProps } from "./types";
@@ -28,9 +29,10 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
const versionDisplay = meta?.version ? `v${meta.version}` : null; const versionDisplay = meta?.version ? `v${meta.version}` : null;
const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm; const themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
return ( return (
<XProvider locale={{ ...zhCN, ...zhCN_X }} theme={{ algorithm: themeAlgorithm }}> <XProvider locale={locale} theme={{ algorithm: themeAlgorithm }}>
<AntApp> <AntApp>
<Layout className="app-layout"> <Layout className="app-layout">
<Header className="app-header"> <Header className="app-header">

View File

@@ -0,0 +1,29 @@
import { Alert, Button } from "antd";
import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router";
export function RouteError() {
const error = useRouteError();
const navigate = useNavigate();
const message = isRouteErrorResponse(error)
? `${error.status} ${error.statusText}`
: error instanceof Error
? error.message
: "未知错误";
return (
<div className="app-loading">
<Alert
action={
<Button onClick={() => void navigate("/")} size="small" type="primary">
</Button>
}
description={message}
showIcon
title="页面加载出错"
type="error"
/>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { useContext } from "react"; import { createContext, useContext } from "react";
import type { Project } from "../../../shared/api"; import type { Project } from "../../../shared/api";
import { ProjectContext } from "./ProjectContextValue"; export const ProjectContext = createContext<null | Project>(null);
export function useCurrentProject(): Project { export function useCurrentProject(): Project {
const project = useContext(ProjectContext); const project = useContext(ProjectContext);

View File

@@ -1,6 +1,6 @@
import { theme } from "antd"; import { useThemePreference } from "./use-theme-preference";
export function useIsDark(): boolean { export function useIsDark(): boolean {
const { token } = theme.useToken(); const { effectiveTheme } = useThemePreference();
return token.colorBgBase === "#000"; return effectiveTheme === "dark";
} }

View File

@@ -1,5 +1,5 @@
import { App } from "antd"; import { App } from "antd";
import { useMemo, useState } from "react"; import { useMemo } from "react";
import type { Logger } from "../utils/logger"; import type { Logger } from "../utils/logger";
@@ -7,19 +7,12 @@ import { AntdMessageSink, ConsoleSink, createDefaultLogger } from "../utils/logg
export function useLogger(bindings?: Record<string, unknown>): Logger { export function useLogger(bindings?: Record<string, unknown>): Logger {
const { message } = App.useApp(); const { message } = App.useApp();
const [stableJson, setStableJson] = useState(() => JSON.stringify(bindings ?? {})); const bindingsKey = JSON.stringify(bindings ?? {});
const [stableBindings, setStableBindings] = useState(() => bindings);
const currentJson = JSON.stringify(bindings ?? {});
if (currentJson !== stableJson) {
setStableJson(currentJson);
setStableBindings(bindings);
}
return useMemo(() => { return useMemo(() => {
const isProduction = !!import.meta.env["PROD"]; const isProduction = !!import.meta.env["PROD"];
const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction); const base = createDefaultLogger([new ConsoleSink(isProduction), new AntdMessageSink(message)], isProduction);
if (!stableBindings || Object.keys(stableBindings).length === 0) return base; if (bindingsKey === "{}") return base;
return base.child(stableBindings); return base.child(JSON.parse(bindingsKey) as Record<string, unknown>);
}, [message, stableBindings]); }, [message, bindingsKey]);
} }

View File

@@ -83,8 +83,10 @@ describe("ChatPanel", () => {
renderWithProviders( renderWithProviders(
createElement(ChatPanel, { createElement(ChatPanel, {
conversationId: null, conversationId: null,
defaultModelId: "model-1",
onConversationCreated: noop, onConversationCreated: noop,
projectId: PROJECT_ID, projectId: PROJECT_ID,
textModels: [TEXT_MODEL],
}), }),
); );
@@ -102,8 +104,10 @@ describe("ChatPanel", () => {
renderWithProviders( renderWithProviders(
createElement(ChatPanel, { createElement(ChatPanel, {
conversationId: null, conversationId: null,
defaultModelId: "model-1",
onConversationCreated: onCreated, onConversationCreated: onCreated,
projectId: PROJECT_ID, projectId: PROJECT_ID,
textModels: [TEXT_MODEL],
}), }),
); );
@@ -133,8 +137,10 @@ describe("ChatPanel", () => {
renderWithProviders( renderWithProviders(
createElement(ChatPanel, { createElement(ChatPanel, {
conversationId: null, conversationId: null,
defaultModelId: "model-1",
onConversationCreated: noop, onConversationCreated: noop,
projectId: PROJECT_ID, projectId: PROJECT_ID,
textModels: [TEXT_MODEL],
}), }),
); );
@@ -154,8 +160,10 @@ describe("ChatPanel", () => {
renderWithProviders( renderWithProviders(
createElement(ChatPanel, { createElement(ChatPanel, {
conversationId: "conv-1", conversationId: "conv-1",
defaultModelId: "model-1",
onConversationCreated: noop, onConversationCreated: noop,
projectId: PROJECT_ID, projectId: PROJECT_ID,
textModels: [TEXT_MODEL],
}), }),
); );