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
+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 {