From 603deee962de8c4bea6477069ce0f6047c90739a Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Thu, 21 May 2026 11:42:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor(layout):=20=E9=87=8D=E6=9E=84=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=B8=83=E5=B1=80=E7=BB=93=E6=9E=84=E5=92=8C=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=89=AF=E4=BD=9C=E7=94=A8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 AppShell 组件,将布局逻辑直接集成到各层级 layout - 提取全局 Provider 到 AppProviders 组件统一管理 - 移除独立的 ThemeSync 和 QueryProvider 组件 - 将 AntThemeProvider 功能整合到 AppProviders - 更新用户状态和主题相关的 prop 传递方式 - 优化管理后台菜单结构为全局常量定义 - 迁移页面私有 hooks 到对应页面目录下 - 提取通用 UI 副作用动作为全局 hooks 以减少重复代码 --- AGENTS.md | 5 +- docs/pending-test.md | 1 + web/src/app/(admin)/admin/assets/page.tsx | 15 ++---- .../{hooks => assets}/use-admin-assets.ts | 29 +++--------- web/src/app/(admin)/admin/layout.tsx | 20 +++----- web/src/app/(admin)/admin/prompts/page.tsx | 15 ++---- .../{hooks => prompts}/use-admin-prompts.ts | 41 +++++----------- web/src/app/(admin)/admin/settings/page.tsx | 5 +- web/src/app/(user)/asset-library/page.tsx | 12 ++--- web/src/app/(user)/assets/page.tsx | 12 ++--- .../(user)/canvas/[id]/canvas-client-page.tsx | 5 -- web/src/app/(user)/layout.tsx | 22 +++++++-- web/src/app/(user)/prompts/page.tsx | 12 ++--- web/src/app/layout.tsx | 13 +---- web/src/app/not-found.tsx | 6 +-- web/src/components/ant-theme-provider.tsx | 25 ---------- web/src/components/app-providers.tsx | 47 +++++++++++++++++++ web/src/components/app-shell.tsx | 33 ------------- web/src/components/app-top-nav.tsx | 4 -- web/src/components/query-provider.tsx | 18 ------- web/src/components/theme-sync.tsx | 8 ---- web/src/components/user-session-sync.tsx | 15 ------ web/src/components/user-status-actions.tsx | 20 ++++---- web/src/hooks/use-copy-text.ts | 13 +++++ web/src/stores/use-theme-store.ts | 12 ----- 25 files changed, 151 insertions(+), 257 deletions(-) rename web/src/app/(admin)/admin/{hooks => assets}/use-admin-assets.ts (85%) rename web/src/app/(admin)/admin/{hooks => prompts}/use-admin-prompts.ts (77%) delete mode 100644 web/src/components/ant-theme-provider.tsx create mode 100644 web/src/components/app-providers.tsx delete mode 100644 web/src/components/app-shell.tsx delete mode 100644 web/src/components/query-provider.tsx delete mode 100644 web/src/components/theme-sync.tsx delete mode 100644 web/src/components/user-session-sync.tsx create mode 100644 web/src/hooks/use-copy-text.ts diff --git a/AGENTS.md b/AGENTS.md index 3e1a3c5..789ed44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,13 @@ - API 请求统一放在 `web/src/services/api/`。 - 全局或跨页面状态优先放在 `web/src/stores/`。 - 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。 +- 多个页面重复出现的 UI 副作用动作,例如复制文本并提示、下载并提示、统一确认弹窗,优先抽成 `web/src/hooks/` 下的全局 hook;不要放进 store,除非它确实是需要共享/订阅的状态。 - 画布相关状态和组件放在 `web/src/app/(user)/canvas/` 内部。 - 页面里只有一个主业务组件时直接写在 `page.tsx`,不要单独拆 `Manager` 组件再传一堆 props。 +- 不要新增只做简单转发的组件,例如只 `return {children}` 或只换个名字透传 props;直接在使用处使用真实组件或把逻辑写进当前文件。 +- 页面私有 hook 放在对应页面目录下,例如 `admin/assets/use-admin-assets.ts`;只有多个页面真实复用的 hook 才放到外层 `hooks/`。 - 管理后台页面私有组件放到各自页面目录的 `components/` 下,例如 `admin/assets/components/`、`admin/prompts/components/`;不要为了单页面使用放到 `admin/components/` 共享目录。 -- 管理后台主题、背景、卡片阴影、表格配色等统一在全局 `AntThemeProvider` 或全局 CSS 作用域中配置;页面私有组件不要自己写 `dark ? ...` 主题分支。 +- 管理后台主题、背景、卡片阴影、表格配色等统一在 `web/src/lib/app-theme.ts`、`AppProviders` 或必要的全局 CSS 作用域中配置;页面私有组件不要自己写 `dark ? ...` 主题分支。 - 组件优先使用函数组件和现有 hooks,不新增大型状态管理方案。 - UI 图标优先使用 `lucide-react` 或项目已经使用的 Ant Design 图标。 - 页面文案保持中文。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 37735b7..7e0c441 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -7,3 +7,4 @@ - 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。 - 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。 - 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。 +- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。 diff --git a/web/src/app/(admin)/admin/assets/page.tsx b/web/src/app/(admin)/admin/assets/page.tsx index eb02b10..0641004 100644 --- a/web/src/app/(admin)/admin/assets/page.tsx +++ b/web/src/app/(admin)/admin/assets/page.tsx @@ -2,12 +2,12 @@ import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons"; import { ProTable, type ProColumns } from "@ant-design/pro-components"; -import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd"; +import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd"; import { useEffect, useState } from "react"; -import copy from "copy-to-clipboard"; +import { useCopyText } from "@/hooks/use-copy-text"; import type { AdminAsset } from "@/services/api/admin"; -import { useAdminAssets } from "../hooks/use-admin-assets"; +import { useAdminAssets } from "./use-admin-assets"; type AssetFormValues = Partial & { tagText?: string }; @@ -21,7 +21,7 @@ const editTypeOptions = typeOptions.slice(1); export default function AdminAssetsPage() { const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets(); - const { message } = App.useApp(); + const copyText = useCopyText(); const [form] = Form.useForm(); const [editingAsset, setEditingAsset] = useState | null>(null); const [detailAsset, setDetailAsset] = useState(null); @@ -33,11 +33,6 @@ export default function AdminAssetsPage() { if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" }); }, [editingAsset, form]); - const copyValue = async (value: string) => { - copy(value); - message.success("已复制"); - }; - const saveAsset = async () => { const value = await form.validateFields(); const nextType = value.type || "text"; @@ -150,7 +145,7 @@ export default function AdminAssetsPage() { {detailAsset.description ? {detailAsset.description} : null} - + ) : null} diff --git a/web/src/app/(admin)/admin/hooks/use-admin-assets.ts b/web/src/app/(admin)/admin/assets/use-admin-assets.ts similarity index 85% rename from web/src/app/(admin)/admin/hooks/use-admin-assets.ts rename to web/src/app/(admin)/admin/assets/use-admin-assets.ts index 06dd24d..e6eb778 100644 --- a/web/src/app/(admin)/admin/hooks/use-admin-assets.ts +++ b/web/src/app/(admin)/admin/assets/use-admin-assets.ts @@ -53,17 +53,13 @@ export function useAdminAssets() { if (query.isError) { const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败"; message.error(errorMessage); - if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) { - clearSession(); - } + if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); } }, [clearSession, message, query.error, query.isError]); const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => { const queryState = { keyword, type, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) { - queryState.page = 1; - } + if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; setKeyword(queryState.keyword); setType(queryState.type); setTag(queryState.tag); @@ -71,20 +67,7 @@ export function useAdminAssets() { setPageSize(queryState.pageSize); }; - const refreshAssets = async () => { - await query.refetch(); - }; - - const saveAsset = async (asset: Partial) => { - await saveMutation.mutateAsync(asset); - }; - - const deleteAsset = async (id: string) => { - await deleteMutation.mutateAsync(id); - }; - const data = query.data; - const isLoading = query.isFetching || saveMutation.isPending || deleteMutation.isPending; return { assets: data?.items || [], @@ -95,15 +78,15 @@ export function useAdminAssets() { page, pageSize, total: data?.total || 0, - isLoading, + isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending, searchAssets: (value = keyword) => updateFilters({ keyword: value }), changeKind: (value: string) => updateFilters({ type: value, tag: [] }), changeTag: (value: string[]) => updateFilters({ tag: value }), changePage: (value: number) => updateFilters({ page: value }), changePageSize: (value: number) => updateFilters({ pageSize: value }), resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshAssets, - saveAsset, - deleteAsset, + refreshAssets: () => query.refetch(), + saveAsset: (asset: Partial) => saveMutation.mutateAsync(asset), + deleteAsset: (id: string) => deleteMutation.mutateAsync(id), }; } diff --git a/web/src/app/(admin)/admin/layout.tsx b/web/src/app/(admin)/admin/layout.tsx index 32121ac..3049182 100644 --- a/web/src/app/(admin)/admin/layout.tsx +++ b/web/src/app/(admin)/admin/layout.tsx @@ -10,9 +10,14 @@ import { useEffect } from "react"; import { UserStatusActions } from "@/components/user-status-actions"; import { adminLayoutStyle } from "@/lib/app-theme"; -import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; +const adminMenus = [ + { key: "/admin/prompts", icon: , label: "提示词管理" }, + { key: "/admin/assets", icon: , label: "素材库" }, + { key: "/admin/settings", icon: , label: "系统设置" }, +]; + export default function AdminLayout({ children }: { children: ReactNode }) { const { token: antToken } = theme.useToken(); const router = useRouter(); @@ -21,11 +26,8 @@ export default function AdminLayout({ children }: { children: ReactNode }) { const user = useUserStore((state) => state.user); const isReady = useUserStore((state) => state.isReady); const logout = useUserStore((state) => state.clearSession); - const colorTheme = useThemeStore((state) => state.theme); - const setTheme = useThemeStore((state) => state.setTheme); const activeKey = pathname.startsWith("/admin/settings") ? "/admin/settings" : pathname.startsWith("/admin/assets") ? "/admin/assets" : pathname.startsWith("/admin/prompts") ? "/admin/prompts" : ""; const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : "提示词管理"; - const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; useEffect(() => { if (!isReady) return; @@ -57,11 +59,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { mode="inline" selectedKeys={[activeKey]} style={adminLayoutStyle.menu} - items={[ - { key: "/admin/prompts", icon: , label: 提示词管理, style: adminLayoutStyle.menuItem }, - { key: "/admin/assets", icon: , label: 素材库, style: adminLayoutStyle.menuItem }, - { key: "/admin/settings", icon: , label: 系统设置, style: adminLayoutStyle.menuItem }, - ]} + items={adminMenus.map((item) => ({ ...item, label: {item.label}, style: adminLayoutStyle.menuItem }))} /> @@ -73,11 +71,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) { {pageTitle} , label: "退出登录", onClick: logout }]} /> diff --git a/web/src/app/(admin)/admin/prompts/page.tsx b/web/src/app/(admin)/admin/prompts/page.tsx index 5db1e65..5320af9 100644 --- a/web/src/app/(admin)/admin/prompts/page.tsx +++ b/web/src/app/(admin)/admin/prompts/page.tsx @@ -2,16 +2,16 @@ import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from "@ant-design/icons"; import { ProTable, type ProColumns } from "@ant-design/pro-components"; -import { App, Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd"; +import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Table, Tag, Tooltip, Typography } from "antd"; import { useEffect, useState } from "react"; -import copy from "copy-to-clipboard"; +import { useCopyText } from "@/hooks/use-copy-text"; import type { Prompt } from "@/services/api/prompts"; -import { useAdminPrompts } from "../hooks/use-admin-prompts"; +import { useAdminPrompts } from "./use-admin-prompts"; export default function AdminPromptsPage() { const { categories, prompts, tags, keyword, category, tag, page, pageSize, total, isLoading, isSyncing, searchPrompts, changeCategory, changeTag, changePage, changePageSize, resetFilters, refreshPrompts, syncCategory, savePrompt: saveAdminPrompt, deletePrompt } = useAdminPrompts(); - const { message } = App.useApp(); + const copyText = useCopyText(); const [form] = Form.useForm & { tagText?: string }>(); const [editingPrompt, setEditingPrompt] = useState | null>(null); const [detailPrompt, setDetailPrompt] = useState(null); @@ -26,11 +26,6 @@ export default function AdminPromptsPage() { if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" }); }, [editingPrompt, form]); - const copyPrompt = async (value: string) => { - copy(value); - message.success("已复制"); - }; - const savePrompt = async () => { const value = await form.validateFields(); await saveAdminPrompt({ ...editingPrompt, ...value, category: value.category || defaultCategory, tags: (value.tagText || "").split(",").map((item) => item.trim()).filter(Boolean) }); @@ -129,7 +124,7 @@ export default function AdminPromptsPage() { {detailPrompt.preview ? {detailPrompt.preview} : null} - + {detailPrompt.githubUrl ? : null} diff --git a/web/src/app/(admin)/admin/hooks/use-admin-prompts.ts b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts similarity index 77% rename from web/src/app/(admin)/admin/hooks/use-admin-prompts.ts rename to web/src/app/(admin)/admin/prompts/use-admin-prompts.ts index 455e472..86b8208 100644 --- a/web/src/app/(admin)/admin/hooks/use-admin-prompts.ts +++ b/web/src/app/(admin)/admin/prompts/use-admin-prompts.ts @@ -72,30 +72,16 @@ export function useAdminPrompts() { }); useEffect(() => { - if (categoriesQuery.isError) { - const errorMessage = categoriesQuery.error instanceof Error ? categoriesQuery.error.message : "读取提示词分类失败"; - message.error(errorMessage); - if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) { - clearSession(); - } - } - }, [categoriesQuery.error, categoriesQuery.isError, clearSession, message]); - - useEffect(() => { - if (promptsQuery.isError) { - const errorMessage = promptsQuery.error instanceof Error ? promptsQuery.error.message : "读取提示词失败"; - message.error(errorMessage); - if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) { - clearSession(); - } - } - }, [clearSession, message, promptsQuery.error, promptsQuery.isError]); + const error = categoriesQuery.error || promptsQuery.error; + if (!error) return; + const errorMessage = error instanceof Error ? error.message : "读取提示词失败"; + message.error(errorMessage); + if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession(); + }, [categoriesQuery.error, clearSession, message, promptsQuery.error]); const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => { const queryState = { keyword, category, tag, page, pageSize, ...next }; - if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) { - queryState.page = 1; - } + if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1; setKeyword(queryState.keyword); setCategory(queryState.category); setTag(queryState.tag); @@ -103,13 +89,7 @@ export function useAdminPrompts() { setPageSize(queryState.pageSize); }; - const refreshPrompts = async () => { - await categoriesQuery.refetch(); - await promptsQuery.refetch(); - }; - const data = promptsQuery.data; - const isLoading = categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending; return { categories: categoriesQuery.data || [], @@ -121,7 +101,7 @@ export function useAdminPrompts() { page, pageSize, total: data?.total || 0, - isLoading, + isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending, isSyncing: syncMutation.isPending, syncCategory: (category: string) => syncMutation.mutateAsync(category), searchPrompts: (value = keyword) => updateFilters({ keyword: value }), @@ -130,7 +110,10 @@ export function useAdminPrompts() { changePage: (value: number) => updateFilters({ page: value }), changePageSize: (value: number) => updateFilters({ pageSize: value }), resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }), - refreshPrompts, + refreshPrompts: async () => { + await categoriesQuery.refetch(); + await promptsQuery.refetch(); + }, savePrompt: (prompt: Partial) => saveMutation.mutateAsync(prompt), deletePrompt: (id: string) => deleteMutation.mutateAsync(id), }; diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 3c31382..d494816 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -458,7 +458,10 @@ function modelSummary(models: string[]) { return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview; } -function parseTabJson(tab: SettingsTabKey, value: string) { +function parseTabJson(tab: "public", value: string): AdminSettings["public"] | null; +function parseTabJson(tab: "private", value: string): AdminSettings["private"] | null; +function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null; +function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null { try { return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial) : normalizePrivateSetting(JSON.parse(value) as Partial); } catch { diff --git a/web/src/app/(user)/asset-library/page.tsx b/web/src/app/(user)/asset-library/page.tsx index 1f5df78..a134f48 100644 --- a/web/src/app/(user)/asset-library/page.tsx +++ b/web/src/app/(user)/asset-library/page.tsx @@ -5,8 +5,8 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd"; import axios from "axios"; -import copy from "copy-to-clipboard"; +import { useCopyText } from "@/hooks/use-copy-text"; import { cn } from "@/lib/utils"; import { useAssetStore } from "@/stores/use-asset-store"; import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets"; @@ -16,6 +16,7 @@ const PAGE_SIZE = 12; export default function AssetLibraryPage() { const { message } = App.useApp(); + const copyText = useCopyText(); const [keyword, setKeyword] = useState(""); const [selectedType, setSelectedType] = useState(""); const [selectedTags, setSelectedTags] = useState([]); @@ -77,11 +78,6 @@ export default function AssetLibraryPage() { } }; - const copyText = async (value: string) => { - copy(value); - message.success("已复制"); - }; - if (!isReady) { return (
@@ -164,8 +160,8 @@ export default function AssetLibraryPage() {
{selectedAsset.description ? {selectedAsset.description} : null}
- {selectedAsset.type === "text" ? : null} - {selectedAsset.type === "image" ? : null} + {selectedAsset.type === "text" ? : null} + {selectedAsset.type === "image" ? : null}
diff --git a/web/src/app/(user)/assets/page.tsx b/web/src/app/(user)/assets/page.tsx index 4d634a2..689eefb 100644 --- a/web/src/app/(user)/assets/page.tsx +++ b/web/src/app/(user)/assets/page.tsx @@ -3,8 +3,8 @@ import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd"; -import copy from "copy-to-clipboard"; +import { useCopyText } from "@/hooks/use-copy-text"; import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils"; import { uploadImage } from "@/services/image-storage"; import { cn } from "@/lib/utils"; @@ -30,6 +30,7 @@ const kindOptions = [ export default function AssetsPage() { const { message } = App.useApp(); + const copyText = useCopyText(); const [form] = Form.useForm(); const coverInputRef = useRef(null); const imageInputRef = useRef(null); @@ -138,10 +139,9 @@ export default function AssetsPage() { if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); }; - const copyText = async (asset: Asset) => { + const copyAssetText = async (asset: Asset) => { if (asset.kind !== "text") return; - copy(asset.data.content); - message.success("文本已复制"); + copyText(asset.data.content, "文本已复制"); }; const downloadImage = (asset: Asset) => { @@ -199,7 +199,7 @@ export default function AssetsPage() { asset={asset} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} - onCopy={copyText} + onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} /> @@ -286,7 +286,7 @@ export default function AssetsPage() { }} /> - setPreviewAsset(null)} onCopy={copyText} onDownload={downloadImage} /> + setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} /> setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index 90eb482..68a0687 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -2093,9 +2093,7 @@ function CanvasTopBar({ onExpandAssistant: () => void; }) { const colorTheme = useThemeStore((state) => state.theme); - const setTheme = useThemeStore((state) => state.setTheme); const theme = canvasThemes[colorTheme]; - const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; const initial = (userName.trim()[0] || "U").toUpperCase(); const titleRef = useRef(null); const accountRef = useRef(null); @@ -2175,9 +2173,6 @@ function CanvasTopBar({
{children}; + const pathname = usePathname(); + const config = useAiConfigStore((state) => state.config); + const updateConfig = useAiConfigStore((state) => state.updateConfig); + const slug = pathname.split("/").filter(Boolean)[0]; + const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; + + return ( +
+ +
{children}
+
+ ); } diff --git a/web/src/app/(user)/prompts/page.tsx b/web/src/app/(user)/prompts/page.tsx index d94c06d..b58db1a 100644 --- a/web/src/app/(user)/prompts/page.tsx +++ b/web/src/app/(user)/prompts/page.tsx @@ -3,11 +3,11 @@ import { FolderPlus, Search } from "lucide-react"; import { type UIEvent, useEffect, useState } from "react"; import { App, Button, Empty, Input, Spin, Tag } from "antd"; -import copy from "copy-to-clipboard"; import { PromptCard } from "@/components/prompts/prompt-card"; import { PromptDetailDialog } from "@/components/prompts/prompt-detail-dialog"; import { usePromptList } from "@/components/prompts/use-prompt-list"; +import { useCopyText } from "@/hooks/use-copy-text"; import { cn } from "@/lib/utils"; import { useAssetStore } from "@/stores/use-asset-store"; import { ALL_PROMPTS_OPTION, type Prompt } from "@/services/api/prompts"; @@ -19,6 +19,7 @@ export default function PromptsPage() { const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION); const [selectedPrompt, setSelectedPrompt] = useState(null); const addAsset = useAssetStore((state) => state.addAsset); + const copyText = useCopyText(); const { query, items: promptItems, tags: promptTags, categories: promptCategoryOptions, total: totalPrompts } = usePromptList({ keyword: titleKeyword, tags: selectedTags, category: selectedCategory }); useEffect(() => { @@ -32,11 +33,6 @@ export default function PromptsPage() { setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]); }; - const copyPrompt = async (prompt: string) => { - copy(prompt); - message.success("提示词已复制"); - }; - const savePromptAsset = (item: Prompt) => { addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } }); message.success("已加入我的素材"); @@ -93,7 +89,7 @@ export default function PromptsPage() {
{promptItems.map((item) => ( - setSelectedPrompt(item)} onCopy={() => void copyPrompt(item.prompt)} extraAction={} /> + setSelectedPrompt(item)} onCopy={() => copyText(item.prompt, "提示词已复制")} extraAction={} /> ))}
{promptItems.length === 0 ? : null} @@ -102,7 +98,7 @@ export default function PromptsPage() { ) : null} - setSelectedPrompt(null)} onCopy={(prompt) => void copyPrompt(prompt)} onSaveAsset={savePromptAsset} /> + setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
); } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 0d71ebd..45e4a9c 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,8 +1,5 @@ import type { Metadata } from "next"; -import { AntThemeProvider } from "@/components/ant-theme-provider"; -import { QueryProvider } from "@/components/query-provider"; -import { ThemeSync } from "@/components/theme-sync"; -import { UserSessionSync } from "@/components/user-session-sync"; +import { AppProviders } from "@/components/app-providers"; import "antd/dist/reset.css"; import "./globals.css"; @@ -30,13 +27,7 @@ export default function RootLayout({ __html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`, }} /> - - - - - {children} - - + {children} ); diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx index 6221209..b0e215d 100644 --- a/web/src/app/not-found.tsx +++ b/web/src/app/not-found.tsx @@ -1,11 +1,9 @@ import { Home, LogIn } from "lucide-react"; import Link from "next/link"; -import { AppShell } from "@/components/app-shell"; - export default function NotFound() { return ( - +
@@ -27,6 +25,6 @@ export default function NotFound() {
- +
); } diff --git a/web/src/components/ant-theme-provider.tsx b/web/src/components/ant-theme-provider.tsx deleted file mode 100644 index 4bd284a..0000000 --- a/web/src/components/ant-theme-provider.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { ProConfigProvider } from "@ant-design/pro-components"; -import { App, ConfigProvider } from "antd"; -import zhCN from "antd/locale/zh_CN"; - -import { getAntThemeConfig } from "@/lib/app-theme"; -import { useThemeStore } from "@/stores/use-theme-store"; - -export function AntThemeProvider({ children }: { children: ReactNode }) { - const theme = useThemeStore((state) => state.theme); - const dark = theme === "dark"; - - return ( - - - {children} - - - ); -} diff --git a/web/src/components/app-providers.tsx b/web/src/components/app-providers.tsx new file mode 100644 index 0000000..9076a84 --- /dev/null +++ b/web/src/components/app-providers.tsx @@ -0,0 +1,47 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { ProConfigProvider } from "@ant-design/pro-components"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { App, ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; + +import { getAntThemeConfig } from "@/lib/app-theme"; +import { useThemeStore } from "@/stores/use-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: false, + refetchOnWindowFocus: false, + }, + }, +}); + +export function AppProviders({ children }: { children: ReactNode }) { + const theme = useThemeStore((state) => state.theme); + const hydrateUser = useUserStore((state) => state.hydrateUser); + const dark = theme === "dark"; + + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + document.documentElement.style.colorScheme = theme; + }, [dark, theme]); + + useEffect(() => { + void hydrateUser(); + }, [hydrateUser]); + + return ( + + + + {children} + + + + ); +} diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx deleted file mode 100644 index 9467f1b..0000000 --- a/web/src/components/app-shell.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { usePathname } from "next/navigation"; - -import { AppTopNav } from "@/components/app-top-nav"; -import { useAiConfigStore } from "@/stores/use-ai-config-store"; -import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools"; - -export function AppShell({ children }: { children: ReactNode }) { - const pathname = usePathname(); - - return {children}; -} - -function MainAppShell({ pathname, children }: { pathname: string; children: ReactNode }) { - const config = useAiConfigStore((state) => state.config); - const updateConfig = useAiConfigStore((state) => state.updateConfig); - const slug = pathname.split("/").filter(Boolean)[0]; - const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; - const isCanvasDetail = /^\/canvas\/[^/]+/.test(pathname); - - return ( - - -
{children}
-
- ); -} - -function ShellFrame({ children }: { children: ReactNode }) { - return
{children}
; -} diff --git a/web/src/components/app-top-nav.tsx b/web/src/components/app-top-nav.tsx index d5b1af5..afadba0 100644 --- a/web/src/components/app-top-nav.tsx +++ b/web/src/components/app-top-nav.tsx @@ -126,11 +126,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
{isReady && user ? ( openConfigDialog(false)} - userName={user.username} menuItems={[ ...(user.role === "admin" ? [{ key: "admin", icon: , label: 管理后台 }] : []), { key: "logout", icon: , label: "退出登录", onClick: logout }, diff --git a/web/src/components/query-provider.tsx b/web/src/components/query-provider.tsx deleted file mode 100644 index 5d10c98..0000000 --- a/web/src/components/query-provider.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); - -export function QueryProvider({ children }: { children: ReactNode }) { - return {children}; -} diff --git a/web/src/components/theme-sync.tsx b/web/src/components/theme-sync.tsx deleted file mode 100644 index 8a71d7c..0000000 --- a/web/src/components/theme-sync.tsx +++ /dev/null @@ -1,8 +0,0 @@ -"use client"; - -import { useSyncThemeClass } from "@/stores/use-theme-store"; - -export function ThemeSync() { - useSyncThemeClass(); - return null; -} diff --git a/web/src/components/user-session-sync.tsx b/web/src/components/user-session-sync.tsx deleted file mode 100644 index 54ba5a5..0000000 --- a/web/src/components/user-session-sync.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -import { useUserStore } from "@/stores/use-user-store"; - -export function UserSessionSync() { - const hydrateUser = useUserStore((state) => state.hydrateUser); - - useEffect(() => { - void hydrateUser(); - }, [hydrateUser]); - - return null; -} diff --git a/web/src/components/user-status-actions.tsx b/web/src/components/user-status-actions.tsx index e355c39..c5c3184 100644 --- a/web/src/components/user-status-actions.tsx +++ b/web/src/components/user-status-actions.tsx @@ -9,12 +9,10 @@ import { GitHubLink } from "@/components/github-link"; import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; import { VersionReleaseModal } from "@/components/version-release-modal"; import { cn } from "@/lib/utils"; -import type { ThemeName } from "@/stores/use-theme-store"; +import { useThemeStore } from "@/stores/use-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; type UserStatusActionsProps = { - version: string; - theme: ThemeName; - onThemeChange: (theme: ThemeName) => void; onOpenConfig?: () => void; showConfig?: boolean; userName?: string; @@ -34,9 +32,6 @@ type UserStatusActionsProps = { }; export function UserStatusActions({ - version, - theme, - onThemeChange, onOpenConfig, showConfig = true, userName, @@ -54,7 +49,12 @@ export function UserStatusActions({ userLabel, iconStyle, }: UserStatusActionsProps) { - const avatarText = initial || (userName?.trim()[0] || "U").toUpperCase(); + const theme = useThemeStore((state) => state.theme); + const setTheme = useThemeStore((state) => state.setTheme); + const storedUserName = useUserStore((state) => state.user?.username); + const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev"; + const resolvedUserName = userName || storedUserName; + const avatarText = initial || (resolvedUserName?.trim()[0] || "U").toUpperCase(); const naturalIconClass = "inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"; return ( @@ -73,13 +73,13 @@ export function UserStatusActions({ ) : null} - +
{ + copy(value); + message.success(successText); + }; +} diff --git a/web/src/stores/use-theme-store.ts b/web/src/stores/use-theme-store.ts index 61d5b3f..49f65cd 100644 --- a/web/src/stores/use-theme-store.ts +++ b/web/src/stores/use-theme-store.ts @@ -1,6 +1,3 @@ -"use client"; - -import { useEffect } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; @@ -20,12 +17,3 @@ export const useThemeStore = create()( { name: "infinite-canvas:theme_store" }, ), ); - -export function useSyncThemeClass() { - const theme = useThemeStore((state) => state.theme); - - useEffect(() => { - document.documentElement.classList.toggle("dark", theme === "dark"); - document.documentElement.style.colorScheme = theme; - }, [theme]); -}