Compare commits

...

2 Commits

Author SHA1 Message Date
5b09a16bc3 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 缓存
2026-06-03 11:32:28 +08:00
297293cb61 chore: 移除 .pi 和 .claude 目录及其 gitignore 规则 2026-06-03 09:46:36 +08:00
21 changed files with 342 additions and 277 deletions

5
.gitignore vendored
View File

@@ -399,13 +399,8 @@ env/
cython_debug/
# Custom
.claude/*
!.claude/settings.json
.opencode
.codex
.pi/*
!.pi/mcp.json
!.pi/extensions
openspec/changes/archive
temp
.agents

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-permission-system/main/schemas/permissions.schema.json",
"permission": {
"*": "allow",
"write": "allow",
"edit": "allow",
"bash": {
"*": "allow",
"npm *": "deny",
"npx *": "deny",
"pnpm *": "deny",
"pnpx *": "deny"
},
"external_directory": {
"*": "ask",
"/tmp/*": "allow"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"tdesign-mcp-server": {
"command": "bunx",
"args": ["tdesign-mcp-server@latest"]
}
}
}

View File

@@ -52,17 +52,18 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
### 共享 Hooks
| Hook | 路径 | 说明 |
| ----------------------- | --------------------------------------- | ----------------------------------------- |
| `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-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 |
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `use-is-dark` | `shared/hooks/use-is-dark.ts` | 当前是否暗色主题 |
| Hook | 路径 | 说明 |
| ----------------------- | --------------------------------------- | ---------------------------------------------------------- |
| `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-models.ts` | `shared/hooks/use-models.ts` | 模型 CRUD + test connection |
| `use-projects.ts` | `shared/hooks/use-projects.ts` | 项目 CRUD + archive/restore |
| `use-conversations.ts` | `shared/hooks/use-conversations.ts` | 会话和消息 fetch 函数(不含 Query hooks |
| `use-logger` | `shared/hooks/use-logger.ts` | Logger hook组件内使用 |
| `use-theme-preference` | `shared/hooks/use-theme-preference.ts` | 主题偏好 localStorage 持久化 |
| `use-sidebar-collapsed` | `shared/hooks/use-sidebar-collapsed.ts` | 侧边栏折叠 localStorage 持久化 |
| `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",
"sourceType": "github",
"skillPath": "skills/ant-design/SKILL.md",
"computedHash": "4d0447d48fced080b2825ecc0fb4d7ca836c8015882899c643acca0b864d5179"
"computedHash": "096d4ac9513e43030f960aab49b50168a3d5eb35be86926ac6e96e5998ea9466"
},
"antd": {
"source": "ant-design/antd-skill",
"sourceType": "github",
"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": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"skillPath": "packages/x-skill/skills/x-components/SKILL.md",
"computedHash": "ebc195a3a5020b6d4f4533adf2e0af33253919f0c704947e727f877aba23a4c2"
"computedHash": "efb7661cadf8a35fae32ce9a6b261b82ee8c8a2bb76303b333ff166163c0a729"
},
"x-markdown": {
"source": "ant-design/x",
"ref": "main",
"sourceType": "github",
"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 { 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={{

View File

@@ -2,7 +2,7 @@ import { type ReactNode } from "react";
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 }) {
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 { ConsoleShell } from "../../shared/components/ConsoleShell/ConsoleShell";
import { useCurrentProject } from "../../shared/hooks/use-current-project";
import { ProjectProvider } from "./ProjectContext";
import { getWorkbenchMenuItems } from "./routes";
import { useCurrentProject } from "./useCurrentProject";
interface WorkbenchConsoleLayoutProps {
project: Project;

View File

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

View File

@@ -1,5 +1,17 @@
import { Spin } from "antd";
import { Suspense } from "react";
import { Outlet } from "react-router";
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 { App as AntApp, Layout, Segmented, theme } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useMemo } from "react";
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 themeAlgorithm = effectiveTheme === "dark" ? theme.darkAlgorithm : theme.defaultAlgorithm;
const locale = useMemo(() => ({ ...zhCN, ...zhCN_X }), []);
return (
<XProvider locale={{ ...zhCN, ...zhCN_X }} theme={{ algorithm: themeAlgorithm }}>
<XProvider locale={locale} theme={{ algorithm: themeAlgorithm }}>
<AntApp>
<Layout className="app-layout">
<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 { ProjectContext } from "./ProjectContextValue";
export const ProjectContext = createContext<null | Project>(null);
export function useCurrentProject(): Project {
const project = useContext(ProjectContext);

View File

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

View File

@@ -1,5 +1,5 @@
import { App } from "antd";
import { useMemo, useState } from "react";
import { useMemo } from "react";
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 {
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);
}
const bindingsKey = JSON.stringify(bindings ?? {});
return useMemo(() => {
const isProduction = !!import.meta.env["PROD"];
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]);
if (bindingsKey === "{}") return base;
return base.child(JSON.parse(bindingsKey) as Record<string, unknown>);
}, [message, bindingsKey]);
}

View File

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