refactor(layout): 重构应用布局结构和全局副作用处理
- 移除 AppShell 组件,将布局逻辑直接集成到各层级 layout - 提取全局 Provider 到 AppProviders 组件统一管理 - 移除独立的 ThemeSync 和 QueryProvider 组件 - 将 AntThemeProvider 功能整合到 AppProviders - 更新用户状态和主题相关的 prop 传递方式 - 优化管理后台菜单结构为全局常量定义 - 迁移页面私有 hooks 到对应页面目录下 - 提取通用 UI 副作用动作为全局 hooks 以减少重复代码
This commit is contained in:
@@ -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 图标。
|
||||
- 页面文案保持中文。
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
- 管理后台系统设置页面应按公开/私有两个 Tab 区分配置,并支持可视化编辑和手动编辑 JSON,且可保存 public/private 配置。
|
||||
- 管理后台私有配置中的模型渠道应以列表展示、通过抽屉新增编辑,并支持填写权重、远程获取模型列表、模型清单测试和批量测试。
|
||||
- 管理后台亮色和暗色主题应使用接近 shadcn 的黑白中性色,侧栏、顶部栏、卡片、表格、弹窗和 JSON 编辑器不应再出现偏棕色或默认蓝色主色。
|
||||
- 全局 Provider 和用户布局精简后,首页、工具页、画布详情页、404 页和管理后台应保持原有导航、主题切换、登录态初始化和数据请求能力。
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
-23
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+12
-29
@@ -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),
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncThemeClass } from "@/stores/use-theme-store";
|
||||
|
||||
export function ThemeSync() {
|
||||
useSyncThemeClass();
|
||||
return 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user