refactor(layout): 重构应用布局结构和全局副作用处理

- 移除 AppShell 组件,将布局逻辑直接集成到各层级 layout
- 提取全局 Provider 到 AppProviders 组件统一管理
- 移除独立的 ThemeSync 和 QueryProvider 组件
- 将 AntThemeProvider 功能整合到 AppProviders
- 更新用户状态和主题相关的 prop 传递方式
- 优化管理后台菜单结构为全局常量定义
- 迁移页面私有 hooks 到对应页面目录下
- 提取通用 UI 副作用动作为全局 hooks 以减少重复代码
This commit is contained in:
HouYunFei
2026-05-21 11:42:22 +08:00
parent dce6ab2282
commit 603deee962
25 changed files with 151 additions and 257 deletions
+4 -1
View File
@@ -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 <X>{children}</X>` 或只换个名字透传 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 图标。
- 页面文案保持中文。
+1
View File
@@ -7,3 +7,4 @@
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。
- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。
+5 -10
View File
@@ -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<AdminAsset> & { 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<AssetFormValues>();
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(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() {
</Flex>
{detailAsset.description ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailAsset.description}</Typography.Paragraph> : null}
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
<Button icon={<CopyOutlined />} onClick={() => void copyValue(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}></Button>
</Flex>
) : null}
</Modal>
@@ -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<AdminAsset>) => {
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<AdminAsset>) => saveMutation.mutateAsync(asset),
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
};
}
+7 -13
View File
@@ -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: <FileTextOutlined />, label: "提示词管理" },
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
{ key: "/admin/settings", icon: <SettingOutlined />, 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: <FileTextOutlined />, label: <Link href="/admin/prompts" style={{ color: "inherit" }}></Link>, style: adminLayoutStyle.menuItem },
{ key: "/admin/assets", icon: <PictureOutlined />, label: <Link href="/admin/assets" style={{ color: "inherit" }}></Link>, style: adminLayoutStyle.menuItem },
{ key: "/admin/settings", icon: <SettingOutlined />, label: <Link href="/admin/settings" style={{ color: "inherit" }}></Link>, style: adminLayoutStyle.menuItem },
]}
items={adminMenus.map((item) => ({ ...item, label: <Link href={item.key} style={{ color: "inherit" }}>{item.label}</Link>, style: adminLayoutStyle.menuItem }))}
/>
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer"></Button>
@@ -73,11 +71,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<Typography.Title level={5} style={{ margin: 0 }}>{pageTitle}</Typography.Title>
<Flex align="center" gap={4}>
<UserStatusActions
version={appVersion}
theme={colorTheme}
onThemeChange={setTheme}
showConfig={false}
userName={user.username}
menuItems={[{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout }]}
/>
</Flex>
+5 -10
View File
@@ -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<Partial<Prompt> & { tagText?: string }>();
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(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 ? <Typography.Paragraph type="secondary" style={{ margin: 0 }}>{detailPrompt.preview}</Typography.Paragraph> : null}
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
<Space>
<Button icon={<CopyOutlined />} onClick={() => void copyPrompt(detailPrompt.prompt)}></Button>
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}></Button>
{detailPrompt.githubUrl ? <Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank"></Button> : null}
</Space>
</Flex>
@@ -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<Prompt>) => saveMutation.mutateAsync(prompt),
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
};
+4 -1
View File
@@ -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<AdminSettings["public"]>) : normalizePrivateSetting(JSON.parse(value) as Partial<AdminSettings["private"]>);
} catch {
+4 -8
View File
@@ -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<string[]>([]);
@@ -77,11 +78,6 @@ export default function AssetLibraryPage() {
}
};
const copyText = async (value: string) => {
copy(value);
message.success("已复制");
};
if (!isReady) {
return (
<div className="flex h-full items-center justify-center">
@@ -164,8 +160,8 @@ export default function AssetLibraryPage() {
</div>
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
<div className="flex flex-wrap gap-2">
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => void copyText(selectedAsset.url)}></Button> : null}
{selectedAsset.type === "text" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}></Button> : null}
{selectedAsset.type === "image" ? <Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}></Button> : null}
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}></Button>
</div>
</div>
+6 -6
View File
@@ -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<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(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() {
}} />
</Modal>
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyText} onDownload={downloadImage} />
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
@@ -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<HTMLDivElement>(null);
const accountRef = useRef<HTMLDivElement>(null);
@@ -2175,9 +2173,6 @@ function CanvasTopBar({
<div className="pointer-events-auto flex items-center gap-1.5">
<UserStatusActions
version={appVersion}
theme={colorTheme}
onThemeChange={setTheme}
onOpenConfig={onOpenConfig}
userName={userName}
initial={initial}
+19 -3
View File
@@ -1,7 +1,23 @@
import type { ReactNode } from "react";
"use client";
import { AppShell } from "@/components/app-shell";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { AppTopNav } from "@/components/app-top-nav";
import { type NavigationToolSlug, navigationTools } from "@/lib/navigation-tools";
import { useAiConfigStore } from "@/stores/use-ai-config-store";
export default function UserLayout({ children }: { children: ReactNode }) {
return <AppShell>{children}</AppShell>;
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 (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<AppTopNav activeToolSlug={activeToolSlug} config={config} onConfigChange={updateConfig} hideHeader={/^\/canvas\/[^/]+/.test(pathname)} />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</div>
);
}
+4 -8
View File
@@ -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<Prompt | null>(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() {
<div>
<div className="mx-auto grid max-w-7xl gap-5 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{promptItems.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => void copyPrompt(item.prompt)} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} />
<PromptCard key={item.id} item={item} onOpen={() => setSelectedPrompt(item)} onCopy={() => copyText(item.prompt, "提示词已复制")} extraAction={<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => savePromptAsset(item)}></Button>} />
))}
</div>
{promptItems.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-16" /> : null}
@@ -102,7 +98,7 @@ export default function PromptsPage() {
) : null}
</main>
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => void copyPrompt(prompt)} onSaveAsset={savePromptAsset} />
<PromptDetailDialog prompt={selectedPrompt} onClose={() => setSelectedPrompt(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={savePromptAsset} />
</div>
);
}
+2 -11
View File
@@ -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){}`,
}}
/>
<ThemeSync />
<AntThemeProvider>
<QueryProvider>
<UserSessionSync />
{children}
</QueryProvider>
</AntThemeProvider>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
+2 -4
View File
@@ -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 (
<AppShell>
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-10 text-stone-900 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)] dark:text-stone-100">
<section className="w-full max-w-md text-center">
<div className="mx-auto mb-6 flex size-16 items-center justify-center rounded-lg border border-stone-200 bg-white text-2xl font-semibold shadow-sm dark:border-stone-800 dark:bg-stone-900">
@@ -27,6 +25,6 @@ export default function NotFound() {
</div>
</section>
</main>
</AppShell>
</div>
);
}
-25
View File
@@ -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 (
<ConfigProvider
locale={zhCN}
theme={getAntThemeConfig(dark)}
>
<ProConfigProvider dark={dark}>
<App>{children}</App>
</ProConfigProvider>
</ConfigProvider>
);
}
+47
View File
@@ -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 (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}>
<App>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</App>
</ProConfigProvider>
</ConfigProvider>
);
}
-33
View File
@@ -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 <MainAppShell pathname={pathname}>{children}</MainAppShell>;
}
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 (
<ShellFrame>
<AppTopNav activeToolSlug={activeToolSlug} config={config} onConfigChange={updateConfig} hideHeader={isCanvasDetail} />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</ShellFrame>
);
}
function ShellFrame({ children }: { children: ReactNode }) {
return <div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">{children}</div>;
}
-4
View File
@@ -126,11 +126,7 @@ export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader =
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
{isReady && user ? (
<UserStatusActions
version={appVersion}
theme={theme}
onThemeChange={setTheme}
onOpenConfig={() => openConfigDialog(false)}
userName={user.username}
menuItems={[
...(user.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin"></Link> }] : []),
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
-18
View File
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
-8
View File
@@ -1,8 +0,0 @@
"use client";
import { useSyncThemeClass } from "@/stores/use-theme-store";
export function ThemeSync() {
useSyncThemeClass();
return null;
}
-15
View File
@@ -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;
}
+10 -10
View File
@@ -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}
<AnimatedThemeToggler
theme={theme}
onThemeChange={onThemeChange}
onThemeChange={setTheme}
className={naturalIconClass}
style={iconStyle}
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<VersionReleaseModal currentVersion={version} style={versionStyle} />
<VersionReleaseModal currentVersion={appVersion} style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { App } from "antd";
import copy from "copy-to-clipboard";
export function useCopyText() {
const { message } = App.useApp();
return (value: string, successText = "已复制") => {
copy(value);
message.success(successText);
};
}
-12
View File
@@ -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<ThemeStore>()(
{ 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]);
}