style(code): 格式化代码缩进和布局样式

- 统一调整 admin.ts 文件中的接口定义缩进格式
- 优化 animated-theme-toggler.tsx 组件的代码结构和缩进
- 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式
- 整理 app-providers.tsx 中的组件层级缩进
- 标准化 app-theme.ts 中的对象属性缩进格式
This commit is contained in:
HouYunFei
2026-05-22 23:19:25 +08:00
parent cee08cb5a8
commit e319481976
93 changed files with 11405 additions and 10423 deletions
+116 -102
View File
@@ -8,111 +8,125 @@ import { fetchImageModels } from "@/services/api/image";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
export function AppConfigModal() {
const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false);
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const publicSettings = useConfigStore((state) => state.publicSettings);
const effectiveConfig = useEffectiveConfig();
const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false);
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const publicSettings = useConfigStore((state) => state.publicSettings);
const effectiveConfig = useEffectiveConfig();
const modelChannel = publicSettings?.modelChannel;
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
const finishConfig = () => {
setConfigDialogOpen(false);
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue();
};
const finishConfig = () => {
setConfigDialogOpen(false);
if (effectiveMode === "local" && (!config.baseUrl.trim() || !config.apiKey.trim())) return;
if (!modelConfig.imageModel.trim() || !modelConfig.textModel.trim()) return;
if (!allowCustomChannel && config.channelMode !== "remote") updateConfig("channelMode", "remote");
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue();
};
const refreshModels = async () => {
if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
}
setLoadingModels(true);
try {
const models = await fetchImageModels(config);
updateConfig("models", models);
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingModels(false);
}
};
const refreshModels = async () => {
if (effectiveMode === "remote") return;
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
}
setLoadingModels(true);
try {
const models = await fetchImageModels(config);
updateConfig("models", models);
if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]);
if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]);
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingModels(false);
}
};
return (
<Modal
title={<div><div className="text-lg font-semibold">配置</div><div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div></div>}
open={isConfigOpen}
width={760}
centered
onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" onClick={finishConfig}>完成</Button>}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[{ label: "本地直连", value: "local" },{ label: "云端渠道", value: "remote" }]}
/>
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium">模型列表</div>
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
return (
<Modal
title={
<div>
<div className="text-lg font-semibold">配置</div>
<div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div>
</div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>拉取模型列表</Button>
</div>
</>
) : (
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100">云端渠道</div>
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
}
open={isConfigOpen}
width={760}
centered
onCancel={() => setConfigDialogOpen(false)}
footer={
<Button type="primary" onClick={finishConfig}>
完成
</Button>
}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false}>
{allowCustomChannel ? (
<Form.Item label="渠道模式" className="mb-4">
<Segmented
block
size="middle"
value={effectiveMode}
onChange={(value) => updateConfig("channelMode", value as AiConfig["channelMode"])}
options={[
{ label: "本地直连", value: "local" },
{ label: "云端渠道", value: "remote" },
]}
/>
</Form.Item>
) : null}
{effectiveMode === "local" ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => updateConfig("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
</Form.Item>
</div>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="min-w-0">
<div className="text-sm font-medium">模型列表</div>
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
</div>
<Button size="small" loading={loadingModels} onClick={() => void refreshModels()}>
拉取模型列表
</Button>
</div>
</>
) : (
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
<div className="font-medium text-stone-900 dark:text-stone-100">云端渠道</div>
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
</Form.Item>
</div>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
</Form>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-4">
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
</Form.Item>
</div>
{effectiveMode === "local" ? (
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={3} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
) : null}
</Form>
</div>
</Modal>
);
</Modal>
);
}
+23 -23
View File
@@ -12,33 +12,33 @@ import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
defaultOptions: {
queries: {
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
},
},
},
});
export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}>
<App>
<QueryClientProvider client={queryClient}>
<ClientRootInit>{children}</ClientRootInit>
</QueryClientProvider>
</App>
</ProConfigProvider>
</ConfigProvider>
);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}>
<App>
<QueryClientProvider client={queryClient}>
<ClientRootInit>{children}</ClientRootInit>
</QueryClientProvider>
</App>
</ProConfigProvider>
</ConfigProvider>
);
}
+93 -97
View File
@@ -18,106 +18,102 @@ import { cn } from "@/lib/utils";
import { useState } from "react";
export function AppTopNav() {
const pathname = usePathname();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const hideHeader = /^\/canvas\/[^/]+/.test(pathname);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
const pathname = usePathname();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const isReady = useUserStore((state) => state.isReady);
const hideHeader = /^\/canvas\/[^/]+/.test(pathname);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
return (
<>
{!hideHeader ? (
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
<div className="flex min-w-0 items-center">
<Link
href="/"
className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300"
>
<span
className="size-5 shrink-0 bg-current"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
/>
<span className="text-base font-medium">无限画布</span>
</Link>
return (
<>
{!hideHeader ? (
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
<div className="flex min-w-0 items-center">
<Link href="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300">
<span
className="size-5 shrink-0 bg-current"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
/>
<span className="text-base font-medium">无限画布</span>
</Link>
<button
type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单"
title="导航菜单"
>
<Menu className="size-5" />
</button>
<button
type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单"
title="导航菜单"
>
<Menu className="size-5" />
</button>
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
className={cn(
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
active
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
)}
>
<Icon className="size-4" />
<span className="truncate">{tool.label}</span>
</Link>
);
})}
</nav>
</div>
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
className={cn(
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
active
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
)}
>
<Icon className="size-4" />
<span className="truncate">{tool.label}</span>
</Link>
);
})}
</nav>
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
{isReady && user ? (
<UserStatusActions />
) : (
<>
<button
type="button"
className="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"
onClick={() => openConfigDialog(false)}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
<AnimatedThemeToggler
theme={theme}
onThemeChange={setTheme}
className="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"
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<VersionReleaseModal />
<GitHubLink />
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
登录
</Link>
</>
)}
</div>
</div>
</header>
) : null}
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
{isReady && user ? (
<UserStatusActions />
) : (
<>
<button
type="button"
className="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"
onClick={() => openConfigDialog(false)}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
<AnimatedThemeToggler
theme={theme}
onThemeChange={setTheme}
className="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"
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<VersionReleaseModal />
<GitHubLink />
<Link href="/login" className="text-sm font-medium text-stone-600 underline-offset-4 transition hover:text-stone-950 hover:underline dark:text-stone-300 dark:hover:text-stone-100">
登录
</Link>
</>
)}
</div>
</div>
</header>
) : null}
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
<AppConfigModal />
</>
);
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
<AppConfigModal />
</>
);
}
+11 -11
View File
@@ -8,18 +8,18 @@ import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
void loadPublicSettings();
}, [loadPublicSettings]);
useEffect(() => {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
useEffect(() => {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
return <>{children}</>;
return <>{children}</>;
}
+15 -15
View File
@@ -5,22 +5,22 @@ import { GithubOutlined } from "@ant-design/icons";
import { cn } from "@/lib/utils";
type GitHubLinkProps = {
className?: string;
style?: React.CSSProperties;
className?: string;
style?: React.CSSProperties;
};
export function GitHubLink({ className, style }: GitHubLinkProps) {
return (
<a
className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)}
style={style}
href="https://github.com/basketikun/infinite-canvas"
target="_blank"
rel="noreferrer"
aria-label="GitHub"
title="GitHub"
>
<GithubOutlined className="text-base" />
</a>
);
return (
<a
className={cn("inline-flex size-9 shrink-0 items-center justify-center rounded-full text-stone-600 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-white", className)}
style={style}
href="https://github.com/basketikun/infinite-canvas"
target="_blank"
rel="noreferrer"
aria-label="GitHub"
title="GitHub"
>
<GithubOutlined className="text-base" />
</a>
);
}
+27 -29
View File
@@ -7,36 +7,34 @@ import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-
import { cn } from "@/lib/utils";
type MobileNavDrawerProps = {
open: boolean;
activeToolSlug?: NavigationToolSlug;
onClose: () => void;
open: boolean;
activeToolSlug?: NavigationToolSlug;
onClose: () => void;
};
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
active
? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100"
: "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
)}
>
<Icon className="size-5" />
<span>{tool.label}</span>
</Link>
);
})}
</div>
</Drawer>
);
return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
active ? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
)}
>
<Icon className="size-5" />
<span>{tool.label}</span>
</Link>
);
})}
</div>
</Drawer>
);
}
@@ -16,90 +16,60 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
type UserStatusActionsProps = {
showConfig?: boolean;
variant?: "default" | "canvas";
onOpenShortcuts?: () => void;
accountOpen?: boolean;
onAccountOpenChange?: (open: boolean) => void;
accountRef?: RefObject<HTMLDivElement | null>;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
showConfig?: boolean;
variant?: "default" | "canvas";
onOpenShortcuts?: () => void;
accountOpen?: boolean;
onAccountOpenChange?: (open: boolean) => void;
accountRef?: RefObject<HTMLDivElement | null>;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
};
export function UserStatusActions({
showConfig = true,
variant = "default",
onOpenShortcuts,
accountOpen,
onAccountOpenChange,
accountRef,
getPopupContainer,
}: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.clearSession);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.username || "用户";
const avatarText = (userName.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";
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const versionStyle = iconStyle;
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
const gitHubStyle = iconStyle;
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
];
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, accountOpen, onAccountOpenChange, accountRef, getPopupContainer }: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const logout = useUserStore((state) => state.clearSession);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const canvasTheme = canvasThemes[theme];
const userName = user?.username || "用户";
const avatarText = (userName.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";
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const versionStyle = iconStyle;
const gitHubClassName = variant === "canvas" ? "size-11 text-base" : undefined;
const gitHubStyle = iconStyle;
const avatarStyle: CSSProperties | undefined = variant === "canvas" ? { borderColor: canvasTheme.toolbar.border, color: canvasTheme.node.text } : undefined;
const menuItems: ItemType[] = [
{ key: "user", disabled: true, label: <span className="font-medium text-current">{userName}</span> },
...(user?.role === "admin" ? [{ key: "admin", icon: <Shield className="size-4" />, label: <Link href="/admin">管理后台</Link> }] : []),
...(onOpenShortcuts ? [{ key: "shortcuts", icon: <Keyboard className="size-4" />, label: "快捷键", onClick: onOpenShortcuts }] : []),
{ type: "divider" },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", onClick: logout },
];
return (
<div className="inline-flex shrink-0 items-center gap-1.5">
{showConfig ? (
<button
type="button"
className={naturalIconClass}
style={iconStyle}
onClick={() => openConfigDialog(false)}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler
theme={theme}
onThemeChange={setTheme}
className={naturalIconClass}
style={iconStyle}
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown
open={accountOpen}
onOpenChange={onAccountOpenChange}
trigger={["click"]}
placement="bottomRight"
getPopupContainer={getPopupContainer}
styles={{ root: { minWidth: 150 } }}
menu={{ items: menuItems }}
>
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{avatarText}</span>
</button>
</Dropdown>
</div>
</div>
);
return (
<div className="inline-flex shrink-0 items-center gap-1.5">
{showConfig ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
<VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
<div ref={accountRef}>
<Dropdown open={accountOpen} onOpenChange={onAccountOpenChange} trigger={["click"]} placement="bottomRight" getPopupContainer={getPopupContainer} styles={{ root: { minWidth: 150 } }} menu={{ items: menuItems }}>
<button
type="button"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-stone-300 bg-transparent p-0 text-xs font-semibold leading-none text-stone-800 transition hover:border-stone-500 hover:text-stone-950 dark:border-stone-700 dark:text-stone-100 dark:hover:border-stone-400 dark:hover:text-white"
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{avatarText}</span>
</button>
</Dropdown>
</div>
</div>
);
}
@@ -6,93 +6,88 @@ import { useVersionCheck } from "@/hooks/use-version-check";
import { APP_VERSION } from "@/constant/env";
function getTagColor(type: string) {
if (type === "新增") return "green";
if (type === "修复") return "red";
if (type === "调整") return "blue";
if (type === "文档") return "purple";
return "default";
if (type === "新增") return "green";
if (type === "修复") return "red";
if (type === "调整") return "blue";
if (type === "文档") return "purple";
return "default";
}
function getReleaseTitle(version: string) {
return version === "Unreleased" ? "未发布" : version;
return version === "Unreleased" ? "未发布" : version;
}
type VersionReleaseModalProps = {
className?: string;
style?: CSSProperties;
className?: string;
style?: CSSProperties;
};
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
return (
<>
<button
type="button"
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style}
onClick={openReleaseModal}
title="查看版本更新"
>
<span className="relative inline-flex">
{APP_VERSION}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span>
</button>
<Modal
title="版本更新"
open={open}
width={680}
centered
footer={null}
onCancel={() => setOpen(false)}
>
<div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
</div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
<button
return (
<>
<button
type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => void checkLatestRelease(true)}
>
{checking ? "检查中..." : "检查更新"}
</button>
</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
</div>
</div>
<div className="max-h-[56vh] overflow-y-auto pr-2">
<Timeline
items={releases.map((release) => ({
content: (
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style}
onClick={openReleaseModal}
title="查看版本更新"
>
<span className="relative inline-flex">
{APP_VERSION}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span>
</button>
<Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
<div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
</div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
<button
type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => void checkLatestRelease(true)}
>
{checking ? "检查中..." : "检查更新"}
</button>
</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
</div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">{item.type}</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>
))}
</div>
</div>
),
}))}
/>
</div>
</Modal>
</>
);
<div className="max-h-[56vh] overflow-y-auto pr-2">
<Timeline
items={releases.map((release) => ({
content: (
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
</div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
{item.type}
</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>
))}
</div>
</div>
),
}))}
/>
</div>
</Modal>
</>
);
}