Compare commits

...

4 Commits

12 changed files with 100 additions and 85 deletions

View File

@@ -9,7 +9,7 @@
- **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models``/models/providers`
- **WorkbenchLayout**`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统),主题配置由 `shared/theme/theme-config.ts``buildThemeConfig(effectiveTheme)` 集中构建(含 `cssVar``borderRadius``controlHeight``components.Layout` 配色);侧边栏折叠。Header 显示品牌名、版本号和布局标题。
`Sidebar``src/web/shared/components/Sidebar/`)纯展示组件,通过 `menuItems` props 接收配置。
@@ -69,8 +69,14 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
| `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 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享主题配置
| 文件 | 导出 |
| ----------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `theme/theme-config.ts` | `buildThemeConfig(effectiveTheme)` — 构建 antd ThemeConfigalgorithm、cssVar、token、components.Layout |
| `use-current-project` | `shared/hooks/use-current-project.ts` | 当前工作台项目 + ProjectContext需在 ProjectProvider 内) |
| `use-materials.ts` | `shared/hooks/use-materials.ts` | 素材 CRUDcreate/delete/fetch/list + Query hooks |
### 共享工具函数

View File

@@ -38,7 +38,8 @@ export function parseListParams(
}
}
const keyword = url.searchParams.get("keyword") ?? undefined;
const keywordRaw = url.searchParams.get("keyword");
const keyword = keywordRaw === "" ? undefined : (keywordRaw ?? undefined);
const sortBy = url.searchParams.get("sortBy") ?? undefined;
const sortOrderParam = url.searchParams.get("sortOrder") ?? undefined;

View File

@@ -6,7 +6,6 @@ import "overlayscrollbars/styles/overlayscrollbars.css";
import { OverlayScrollbarsComponent, type OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
import { useCallback, useRef, useState } from "react";
import { useIsDark } from "../../shared/hooks/use-is-dark";
import { useChatScroll } from "./use-chat-scroll";
interface ChatScrollAreaProps {
@@ -19,7 +18,6 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
const scrollRef = useRef<HTMLDivElement>(null);
const osRef = useRef<OverlayScrollbarsComponentRef>(null);
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(null);
const isDark = useIsDark();
const handleOsInitialized = useCallback(() => {
const os = osRef.current;
@@ -42,7 +40,7 @@ export function ChatScrollArea({ children, messages, status }: ChatScrollAreaPro
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
theme: "os-theme-custom",
},
}}
ref={osRef}

View File

@@ -7,7 +7,6 @@ import { useMemo, useState } from "react";
import type { Conversation } from "../../../../shared/api";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { ConversationCard } from "./ConversationCard";
@@ -30,7 +29,6 @@ export function ConversationList({
}: ConversationListProps) {
const [inputText, setInputText] = useState("");
const [appliedSearch, setAppliedSearch] = useState("");
const isDark = useIsDark();
const filteredConversations = useMemo(() => {
if (!appliedSearch) return conversations;
@@ -60,7 +58,7 @@ export function ConversationList({
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
theme: "os-theme-custom",
},
}}
>

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { CopyOutlined } from "@ant-design/icons";
import { Button, message } from "antd";
import { App, Button } from "antd";
import { useCallback, useEffect, useState } from "react";
import { codeToHtml } from "shiki";
@@ -14,6 +14,7 @@ interface CodeBlockProps {
}
export function CodeBlock({ children, className: _className, isStreaming }: CodeBlockProps) {
const { message } = App.useApp();
const isDark = useIsDark();
const [highlighted, setHighlighted] = useState<null | string>(null);
const { codeText, lang } = extractCode(children);
@@ -23,7 +24,7 @@ export function CodeBlock({ children, className: _className, isStreaming }: Code
() => message.success("已复制"),
() => message.error("复制失败"),
);
}, [codeText]);
}, [codeText, message]);
useEffect(() => {
if (isStreaming || !codeText) return;
@@ -57,14 +58,16 @@ export function CodeBlock({ children, className: _className, isStreaming }: Code
<div className="code-block">
<div className="code-block-header">
<span className="code-block-lang">{lang}</span>
<Button icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
<Button className="btn-dimmed" icon={<CopyOutlined />} onClick={handleCopy} size="small" type="text" />
</div>
{highlighted ? (
<div className="code-block-body" dangerouslySetInnerHTML={{ __html: highlighted }} />
) : (
<pre className="code-block-body">
<code>{codeText}</code>
</pre>
<div className="code-block-body">
<pre>
<code>{codeText}</code>
</pre>
</div>
)}
</div>
);

View File

@@ -18,7 +18,7 @@ const STATUS_MAP: Record<MaterialStatus, { color: string; label: string }> = {
export function MaterialCard({ material, onDelete, onSelect, selected }: MaterialCardProps) {
const statusInfo = STATUS_MAP[material.status];
const className = selected ? "material-list-item material-list-item--selected" : "material-list-item";
const className = selected ? "app-sidebar-list-item app-sidebar-list-item--selected" : "app-sidebar-list-item";
return (
<Flex align="center" className={className} gap="small" justify="space-between" onClick={onSelect}>

View File

@@ -13,7 +13,6 @@ import { useMemo, useState } from "react";
import type { Material } from "../types";
import { SidebarGroup } from "../../../shared/components/SidebarGroup";
import { useIsDark } from "../../../shared/hooks/use-is-dark";
import { GROUP_LABELS, groupByDate } from "../../../shared/utils/date-group";
import { MaterialCard } from "./MaterialCard";
@@ -37,7 +36,6 @@ type FilterValue = (typeof STATUS_FILTER_OPTIONS)[number]["value"];
export function MaterialList({ loading, materials, onAddClick, onDelete, onSelect, selectedId }: MaterialListProps) {
const [filterStatus, setFilterStatus] = useState<FilterValue>("all");
const isDark = useIsDark();
const filteredMaterials = useMemo(() => {
if (filterStatus === "all") return materials;
@@ -74,7 +72,7 @@ export function MaterialList({ loading, materials, onAddClick, onDelete, onSelec
overflow: { x: "hidden", y: "scroll" },
scrollbars: {
autoHide: "move",
theme: isDark ? "os-theme-custom-dark" : "os-theme-custom",
theme: "os-theme-custom",
},
}}
>

View File

@@ -1,7 +1,7 @@
import type { TableColumnsType, TableProps } from "antd";
import { DeleteOutlined, EditOutlined, InboxOutlined, LoginOutlined, RedoOutlined } from "@ant-design/icons";
import { Button, Popconfirm, Space, Table, Tag } from "antd";
import { Button, Popconfirm, Space, Table, Tag, theme } from "antd";
import { useMemo } from "react";
import { useNavigate } from "react-router";
@@ -40,6 +40,7 @@ export function ProjectTable({
sortOrder,
}: ProjectTableProps) {
const navigate = useNavigate();
const { token: themeToken } = theme.useToken();
const columns = useMemo<TableColumnsType<Project>>(
() => [
@@ -60,7 +61,7 @@ export function ProjectTable({
if (record.status === "archived") {
return <Tag></Tag>;
}
return <Tag color="blue"></Tag>;
return <Tag color={themeToken.colorPrimary}></Tag>;
},
title: "状态",
width: 90,
@@ -137,7 +138,7 @@ export function ProjectTable({
width: 260,
},
],
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder],
[navigate, onEdit, onArchive, onRestore, onDelete, sortBy, sortOrder, themeToken.colorPrimary],
);
const handleTableChange: TableProps<Project>["onChange"] = (pagination, _filters, sorter) => {

View File

@@ -1,7 +1,7 @@
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
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 { App as AntApp, Layout, Segmented } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useMemo } from "react";
@@ -11,6 +11,7 @@ import { APP } from "../../../../shared/app";
import { useMeta } from "../../hooks/use-meta";
import { useSidebarCollapsed } from "../../hooks/use-sidebar-collapsed";
import { useThemePreference } from "../../hooks/use-theme-preference";
import { buildThemeConfig } from "../../theme/theme-config";
import { Sidebar } from "../Sidebar";
import { ConsoleOutlet } from "./ConsoleOutlet";
@@ -28,11 +29,10 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
const { data: meta } = useMeta();
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={locale} theme={{ algorithm: themeAlgorithm }}>
<XProvider locale={locale} theme={buildThemeConfig(effectiveTheme)}>
<AntApp>
<Layout className="app-layout">
<Header className="app-header">
@@ -58,7 +58,7 @@ export function ConsoleShell({ headerExtra, menuItems, title }: ConsoleShellProp
collapsedWidth={64}
collapsible
onCollapse={(c) => setCollapsed(c)}
theme="light"
theme={effectiveTheme === "dark" ? "dark" : "light"}
trigger={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
width={232}
>

View File

@@ -0,0 +1,33 @@
import { theme } from "antd";
import type { EffectiveTheme } from "../hooks/use-theme-preference";
export function buildThemeConfig(effectiveTheme: EffectiveTheme) {
const isDark = effectiveTheme === "dark";
const algorithm = isDark ? theme.darkAlgorithm : theme.defaultAlgorithm;
return {
algorithm,
components: {
Layout: {
bodyBg: isDark ? "#0a0a0a" : "transparent",
headerBg: "transparent",
siderBg: isDark ? "#0a0a0a" : "#ffffff",
triggerBg: isDark ? "#141414" : "#ffffff",
},
Menu: {
itemActiveBg: isDark ? "#1f1f1f" : "#e5e5e5",
itemHoverBg: isDark ? "#1a1a1a" : "#f0f0f0",
itemSelectedBg: isDark ? "#2a2a2a" : "#0a0a0a",
itemSelectedColor: "#ffffff",
},
},
cssVar: {},
token: {
borderRadius: 10,
colorLink: isDark ? "#a3a3a3" : "#0a0a0a",
colorPrimary: isDark ? "#525252" : "#0a0a0a",
controlHeight: 36,
},
};
}

View File

@@ -81,10 +81,22 @@ body {
min-height: 60vh;
}
.ant-layout-sider {
border-right: none;
}
.ant-layout-sider-trigger {
border-right: 1px solid var(--ant-color-border-secondary);
}
.ant-menu {
height: 100%;
}
.app-sidebar-list {
display: flex;
flex-direction: column;
border-right: 1px solid var(--ant-color-border-secondary);
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg);
background: var(--ant-color-bg-container);
}
@@ -118,11 +130,16 @@ body {
}
.app-sidebar-list-item--selected {
background: var(--ant-color-primary-bg);
background: var(--ant-color-primary);
color: var(--ant-color-text-light-solid);
}
.app-sidebar-list-item--selected .ant-typography {
color: var(--ant-color-text-light-solid);
}
.app-sidebar-list-item--selected:hover {
background: var(--ant-color-primary-bg);
background: var(--ant-color-primary);
}
.app-sidebar-item-actions {
@@ -267,17 +284,19 @@ body {
background: var(--ant-color-bg-container);
}
.card-extra-actions .btn-dimmed {
.card-extra-actions .btn-dimmed,
.code-block-header .ant-btn.btn-dimmed {
color: var(--ant-color-text-quaternary);
}
.card-extra-actions .btn-dimmed:hover {
.card-extra-actions .btn-dimmed:hover,
.code-block-header .ant-btn.btn-dimmed:hover {
color: var(--ant-color-text-secondary);
}
.chat-scroll-bottom-btn {
position: absolute;
bottom: 115px;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
display: flex;
@@ -297,23 +316,9 @@ body {
--os-padding-axis: 2px;
--os-track-border-radius: 10px;
--os-handle-border-radius: 10px;
--os-handle-bg: rgba(0, 0, 0, 0.15);
--os-handle-bg-hover: rgba(0, 0, 0, 0.25);
--os-handle-bg-active: rgba(0, 0, 0, 0.35);
--os-handle-min-size: 33px;
--os-handle-max-size: none;
--os-handle-interactive-area-offset: 4px;
}
.os-theme-custom-dark {
--os-size: 8px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--os-track-border-radius: 10px;
--os-handle-border-radius: 10px;
--os-handle-bg: rgba(255, 255, 255, 0.15);
--os-handle-bg-hover: rgba(255, 255, 255, 0.25);
--os-handle-bg-active: rgba(255, 255, 255, 0.35);
--os-handle-bg: var(--ant-color-border-secondary);
--os-handle-bg-hover: var(--ant-color-text-quaternary);
--os-handle-bg-active: var(--ant-color-text-tertiary);
--os-handle-min-size: 33px;
--os-handle-max-size: none;
--os-handle-interactive-area-offset: 4px;
@@ -339,28 +344,6 @@ body {
width: 100%;
}
/* Inbox material list items */
.material-list-item {
border: none;
margin: var(--ant-margin-xxs) var(--ant-margin-xxs);
padding: var(--ant-padding-xs) var(--ant-padding-sm);
border-radius: var(--ant-border-radius-lg);
cursor: pointer;
transition: background 0.15s ease;
}
.material-list-item:hover {
background: var(--ant-color-bg-text-hover);
}
.material-list-item--selected {
background: var(--ant-color-primary-bg);
}
.material-list-item--selected:hover {
background: var(--ant-color-primary-bg);
}
.material-item-right {
position: relative;
display: inline-flex;
@@ -385,11 +368,11 @@ body {
opacity: 0;
}
.material-list-item:hover .material-item-tag {
.app-sidebar-list-item:hover .material-item-tag {
opacity: 0;
}
.material-list-item:hover .material-item-actions {
.app-sidebar-list-item:hover .material-item-actions {
opacity: 1;
}
@@ -425,9 +408,12 @@ body {
.code-block-body {
margin: 0;
}
.code-block-body > pre {
padding: var(--ant-padding-sm);
margin: 0;
overflow-x: auto;
background: var(--ant-color-bg-container);
}
.code-block-body code {

View File

@@ -16,6 +16,7 @@ describe("useConfirmAction", () => {
let resolved = false;
const action = () => {
resolved = true;
return Promise.resolve();
};
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
@@ -24,16 +25,6 @@ describe("useConfirmAction", () => {
expect(resolved).toBe(true);
});
it("handles action error without throwing", async () => {
const action = () => {
throw new Error("失败");
};
const { result } = renderHook(() => useConfirmAction(), { wrapper: createWrapper() });
await act(() => result.current.confirmAction(action, "成功"));
expect(resolved).toBe(true);
});
it("handles action error without throwing", async () => {
const action = () => {
throw new Error("失败");