first commit

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
HouYunFei
2026-05-19 07:53:53 +08:00
commit 472bf8b732
133 changed files with 16869 additions and 0 deletions
+193
View File
@@ -0,0 +1,193 @@
"use client";
import type { ReactNode } from "react";
import { ProConfigProvider } from "@ant-design/pro-components";
import { App, ConfigProvider, theme as antdTheme } from "antd";
import zhCN from "antd/locale/zh_CN";
import { useThemeStore } from "@/stores/use-theme-store";
export function AntThemeProvider({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const colors = dark
? {
bg: "#1f1d1a",
layout: "#181715",
panel: "#24211e",
elevated: "#292524",
fill: "#322e29",
fillHover: "#3a3631",
border: "#44403c",
borderSoft: "rgba(214, 211, 209, 0.18)",
text: "#f5f5f4",
textSecondary: "#d6d3d1",
textTertiary: "#a8a29e",
primary: "#f5f5f4",
primaryText: "#1c1917",
menuSelected: "#3a3631",
tableHeader: "#2b2521",
}
: {
bg: "#fbfaf7",
layout: "#f4f2ed",
panel: "#ffffff",
elevated: "#ffffff",
fill: "#f5f5f4",
fillHover: "#e7e5df",
border: "#d6d3ca",
borderSoft: "rgba(87, 83, 78, 0.18)",
text: "#292524",
textSecondary: "#57534e",
textTertiary: "#78716c",
primary: "#111111",
primaryText: "#ffffff",
menuSelected: "#f5f5f4",
tableHeader: "#f8fafc",
};
return (
<ConfigProvider
locale={zhCN}
theme={{
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
token: {
colorPrimary: colors.primary,
colorPrimaryHover: dark ? "#ffffff" : "#2a2a2a",
colorPrimaryActive: dark ? "#e7e5e4" : "#000000",
colorInfo: colors.primary,
colorBgBase: colors.bg,
colorBgLayout: colors.layout,
colorBgContainer: colors.panel,
colorBgElevated: colors.elevated,
colorFill: colors.fill,
colorFillSecondary: colors.fill,
colorFillTertiary: colors.fillHover,
colorFillQuaternary: dark ? "rgba(245, 245, 244, 0.08)" : "rgba(28, 25, 23, 0.04)",
colorBorder: colors.border,
colorBorderSecondary: colors.borderSoft,
colorSplit: colors.borderSoft,
colorText: colors.text,
colorTextBase: colors.text,
colorTextSecondary: colors.textSecondary,
colorTextTertiary: colors.textTertiary,
colorTextQuaternary: dark ? "#78716c" : "#a8a29e",
colorIcon: colors.textSecondary,
colorIconHover: colors.text,
colorLink: colors.text,
colorLinkHover: colors.text,
colorBgSpotlight: dark ? "#f5f5f4" : "#1c1917",
colorTextLightSolid: dark ? "#1c1917" : "#ffffff",
colorError: "#ff4d4f",
colorErrorHover: "#ff7875",
colorErrorActive: "#d9363e",
colorWarning: "#faad14",
colorBgMask: dark ? "rgba(0, 0, 0, 0.62)" : "rgba(28, 25, 23, 0.35)",
borderRadius: 8,
borderRadiusLG: 12,
boxShadow: dark ? "0 18px 48px rgba(0, 0, 0, 0.46)" : "0 18px 48px rgba(41, 37, 36, 0.16)",
boxShadowSecondary: dark ? "0 0 0 1px rgba(255,255,255,.06)" : "0 1px 2px rgba(15,23,42,.04)",
fontFamily: '"SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
},
components: {
Button: {
primaryColor: colors.primaryText,
defaultColor: colors.text,
defaultBg: dark ? "#1f1d1a" : "#ffffff",
defaultBorderColor: colors.border,
defaultHoverColor: colors.text,
defaultHoverBg: colors.fillHover,
defaultHoverBorderColor: dark ? "#78716c" : "#a8a29e",
dangerColor: "#ffffff",
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
},
Modal: {
contentBg: colors.elevated,
headerBg: colors.elevated,
footerBg: colors.elevated,
titleColor: colors.text,
},
Menu: {
popupBg: colors.elevated,
itemBg: colors.panel,
itemColor: colors.textSecondary,
itemHoverBg: colors.fillHover,
itemHoverColor: colors.text,
itemActiveBg: colors.menuSelected,
itemSelectedBg: colors.menuSelected,
itemSelectedColor: dark ? "#ffffff" : colors.text,
darkItemBg: colors.panel,
darkItemColor: colors.textSecondary,
darkItemHoverBg: colors.fillHover,
darkItemHoverColor: colors.text,
darkItemSelectedBg: colors.menuSelected,
darkItemSelectedColor: "#ffffff",
},
Layout: {
bodyBg: colors.layout,
headerBg: colors.panel,
headerColor: colors.text,
lightSiderBg: colors.panel,
siderBg: colors.panel,
},
Card: {
bodyPadding: 24,
headerBg: colors.panel,
headerHeight: 56,
},
Table: {
borderColor: colors.borderSoft,
cellPaddingBlockMD: 14,
headerBg: colors.tableHeader,
headerColor: colors.text,
rowHoverBg: colors.fill,
},
Segmented: {
trackBg: colors.fill,
itemSelectedBg: colors.primary,
itemSelectedColor: colors.primaryText,
},
Select: {
selectorBg: dark ? "#1f1d1a" : "#ffffff",
optionActiveBg: colors.fillHover,
optionSelectedBg: dark ? "#3f3a35" : "#f5f5f4",
optionSelectedColor: colors.text,
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeOutlineColor: "transparent",
},
Input: {
activeBg: dark ? "#1f1d1a" : "#ffffff",
hoverBg: dark ? "#1f1d1a" : "#ffffff",
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeShadow: "none",
},
InputNumber: {
activeBg: dark ? "#1f1d1a" : "#ffffff",
hoverBg: dark ? "#1f1d1a" : "#ffffff",
activeBorderColor: dark ? "#78716c" : "#a8a29e",
hoverBorderColor: dark ? "#57534e" : "#a8a29e",
activeShadow: "none",
},
Tabs: {
itemColor: colors.textSecondary,
itemSelectedColor: colors.text,
itemHoverColor: colors.text,
inkBarColor: colors.text,
},
Checkbox: {
colorPrimary: colors.primary,
colorPrimaryHover: colors.primary,
},
},
}}
>
<ProConfigProvider dark={dark}>
<App>{children}</App>
</ProConfigProvider>
</ConfigProvider>
);
}
+33
View File
@@ -0,0 +1,33 @@
"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>;
}
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { LogOut, Menu, Settings2, Shield } from "lucide-react";
import Link from "next/link";
import { App, Button, Drawer, Form, Input, Modal } from "antd";
import { useConfigDialogStore } from "@/stores/use-config-dialog-store";
import { ModelPicker } from "@/components/model-picker";
import { GitHubLink } from "@/components/github-link";
import { UserStatusActions } from "@/components/user-status-actions";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import type { AiConfig } from "@/lib/ai-config";
import { navigationTools, type NavigationToolSlug } from "@/lib/navigation-tools";
import { fetchImageModels } from "@/services/api/image";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { cn } from "@/lib/utils";
import { useState } from "react";
type AppTopNavProps = {
activeToolSlug?: NavigationToolSlug;
config: AiConfig;
onConfigChange: <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
hideHeader?: boolean;
};
export function AppTopNav({ activeToolSlug, config, onConfigChange, hideHeader = false }: AppTopNavProps) {
const { message } = App.useApp();
const [loadingModels, setLoadingModels] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || "dev";
const isConfigOpen = useConfigDialogStore((state) => state.isOpen);
const shouldPromptContinue = useConfigDialogStore((state) => state.shouldPromptContinue);
const openConfigDialog = useConfigDialogStore((state) => state.openConfigDialog);
const setConfigDialogOpen = useConfigDialogStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigDialogStore((state) => state.clearPromptContinue);
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 logout = useUserStore((state) => state.clearSession);
const finishConfig = () => {
setConfigDialogOpen(false);
if (!config.baseUrl.trim() || !config.imageModel.trim() || !config.textModel.trim() || !config.apiKey.trim()) return;
if (shouldPromptContinue) {
message.success("配置已保存,请继续刚才的请求");
} else {
message.success("配置已保存");
}
clearPromptContinue();
};
const refreshModels = async () => {
if (!config.baseUrl.trim() || !config.apiKey.trim()) {
message.error("请先填写 Base URL 和 API Key");
return;
}
setLoadingModels(true);
try {
const models = await fetchImageModels(config);
onConfigChange("models", models);
if (models.length && !models.includes(config.imageModel)) onConfigChange("imageModel", models[0]);
if (models.length && !models.includes(config.textModel)) onConfigChange("textModel", models[0]);
message.success("模型列表已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
} finally {
setLoadingModels(false);
}
};
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>
<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
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 },
]}
/>
) : (
<>
<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" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{appVersion}</span>
<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}
<Drawer
title="导航"
placement="left"
size={280}
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
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={() => setMobileNavOpen(false)}
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>
<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={560}
centered
onCancel={() => setConfigDialogOpen(false)}
footer={<Button type="primary" size="large" onClick={finishConfig}></Button>}
>
<div className="pt-1">
<Form layout="vertical" requiredMark={false} size="large">
<Form.Item label="Base URL" className="mb-4">
<Input value={config.baseUrl} onChange={(event) => onConfigChange("baseUrl", event.target.value)} />
</Form.Item>
<Form.Item label="API Key" className="mb-4">
<Input.Password value={config.apiKey} onChange={(event) => onConfigChange("apiKey", event.target.value)} />
</Form.Item>
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 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 loading={loadingModels} onClick={() => void refreshModels()}></Button>
</div>
<Form.Item label="默认生图模型" className="mb-4">
<ModelPicker config={config} value={config.imageModel} onChange={(model) => onConfigChange("imageModel", model)} fullWidth />
</Form.Item>
<Form.Item label="默认文本模型" className="mb-0">
<ModelPicker config={config} value={config.textModel} onChange={(model) => onConfigChange("textModel", model)} fullWidth />
</Form.Item>
</Form>
</div>
</Modal>
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { GithubOutlined } from "@ant-design/icons";
import { cn } from "@/lib/utils";
type GitHubLinkProps = {
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>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState } from "react";
import { LoaderCircle } from "lucide-react";
import { formatDuration } from "@/lib/image-utils";
import { cn } from "@/lib/utils";
const pendingMessages = ["正在创建图片", "马上就好了", "再等等", "正在整理细节"];
export function ImageGenerationPending({ className, label, compact = false }: { className?: string; label?: string; compact?: boolean }) {
const [tick, setTick] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
return () => window.clearInterval(timer);
}, []);
const index = Math.floor(tick / 2) % pendingMessages.length;
const progress = Math.min(98, 10 + (1 - Math.exp(-tick / 28)) * 88);
return (
<div className={cn("relative overflow-hidden bg-stone-100 dark:bg-white/10", compact ? "min-h-24" : "aspect-[4/3]", className)}>
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, rgba(120,113,108,0.35) 1.4px, transparent 1.6px)",
backgroundSize: "16px 16px",
maskImage: "radial-gradient(ellipse at 38% 68%, black 0%, black 28%, transparent 60%)",
}}
/>
<div className="absolute left-4 top-4 flex items-center gap-2 text-[15px] font-medium text-stone-500 dark:text-stone-300">
<LoaderCircle className="size-4 animate-spin" />
<span>{label || pendingMessages[index]}</span>
</div>
<div className="absolute bottom-4 left-4 right-4">
<div className="mb-2 flex items-center justify-between text-xs text-stone-500 dark:text-stone-400">
<span>{formatDuration(tick * 1000)}</span>
<span>{Math.floor(progress)}%</span>
</div>
<div className="h-1.5 rounded-full bg-stone-300/70 dark:bg-white/12">
<div className="h-full rounded-full bg-stone-900 dark:bg-stone-100" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Select } from "antd";
import type { AiConfig } from "@/lib/ai-config";
type ModelPickerProps = {
config: AiConfig;
value?: string;
onChange: (model: string) => void;
className?: string;
fullWidth?: boolean;
placeholder?: string;
onMissingConfig?: () => void;
};
export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) {
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: model }));
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
return (
<Select
showSearch
className={`canvas-control-select ${className || ""}`}
popupMatchSelectWidth={false}
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
value={value || undefined}
placeholder={placeholder}
options={options}
notFoundContent="请先到配置里拉取模型列表"
onChange={onChange}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
if (!options.length) onMissingConfig?.();
}}
filterOption={(input, option) => String(option?.label || "").toLowerCase().includes(input.toLowerCase())}
/>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Copy } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptCard({ item, onOpen, onCopy, actionLabel = "复制", actionIcon = <Copy className="size-3.5" />, actionType = "text", extraAction }: { item: Prompt; onOpen: () => void; onCopy: () => void; actionLabel?: string; actionIcon?: ReactNode; actionType?: "text" | "primary"; extraAction?: ReactNode }) {
return (
<Card
hoverable
className="overflow-hidden"
styles={{ body: { padding: 0 } }}
cover={<button type="button" className="block w-full text-left" onClick={onOpen}><img src={item.coverUrl} alt={item.title} className="aspect-[4/3] w-full object-cover" /></button>}
>
<button type="button" className="block w-full text-left" onClick={onOpen}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{item.title}</h2>
<span className="shrink-0 text-xs text-stone-400 dark:text-stone-500">{formatPromptDate(item.updatedAt)}</span>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-5 text-stone-600 dark:text-stone-400">{item.prompt}</p>
<div className="mt-3 flex flex-wrap gap-1.5">
{item.tags.map((tag) => <Tag key={tag} className="m-0 text-[11px]">{tag}</Tag>)}
</div>
</div>
</button>
<div className="flex items-center gap-2 px-4 pb-4">
<Button block={actionType === "primary"} type={actionType} size="small" icon={actionIcon} onClick={onCopy}>{actionLabel}</Button>
{extraAction}
</div>
</Card>
);
}
@@ -0,0 +1,34 @@
"use client";
import { Copy, FolderPlus } from "lucide-react";
import { Button, Modal, Space, Tag } from "antd";
import { formatPromptDate, type Prompt } from "@/services/api/prompts";
export function PromptDetailDialog({ prompt, onClose, onCopy, onSaveAsset }: { prompt: Prompt | null; onClose: () => void; onCopy: (prompt: string) => void; onSaveAsset?: (prompt: Prompt) => void }) {
return (
<>
<Modal title={prompt?.title} open={Boolean(prompt)} onCancel={onClose} footer={null} width={860}>
{prompt ? (
<>
<div className="grid gap-5 md:grid-cols-[300px_minmax(0,1fr)]">
<div className="space-y-3">
<img src={prompt.coverUrl} alt={prompt.title} className="aspect-[4/3] w-full rounded-lg object-cover" />
{prompt.preview ? <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded-lg bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{prompt.preview}</pre> : null}
</div>
<div className="min-w-0">
<div className="flex flex-wrap gap-1.5">{prompt.tags.map((tag) => <Tag key={tag} className="m-0">{tag}</Tag>)}</div>
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-800 dark:text-stone-300">{prompt.prompt}</p>
<div className="mt-4 text-xs text-stone-500 dark:text-stone-400">{formatPromptDate(prompt.createdAt)} · {formatPromptDate(prompt.updatedAt)}</div>
<Space wrap className="mt-5">
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => onCopy(prompt.prompt)}></Button>
{onSaveAsset ? <Button icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(prompt)}></Button> : null}
</Space>
</div>
</div>
</>
) : null}
</Modal>
</>
);
}
@@ -0,0 +1,80 @@
"use client";
import { Check, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Empty, Input, Modal, Spin, Tag } from "antd";
import { ALL_PROMPTS_OPTION } from "@/services/api/prompts";
import { cn } from "@/lib/utils";
import { PromptCard } from "./prompt-card";
import { usePromptList } from "./use-prompt-list";
export function PromptSelectDialog({ open, onOpenChange, onSelect }: { open: boolean; onOpenChange: (open: boolean) => void; onSelect: (prompt: string) => void }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(ALL_PROMPTS_OPTION);
const { query, items, tags: promptTags, categories: promptCategories } = usePromptList({ keyword, tags: selectedTags, category: selectedCategory, enabled: open });
const toggleTag = (tag: string) => {
if (tag === ALL_PROMPTS_OPTION) return setSelectedTags([]);
setSelectedTags((items) => items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]);
};
const selectPrompt = (prompt: string) => {
onSelect(prompt);
onOpenChange(false);
};
useEffect(() => {
if (query.isError) message.error(query.error instanceof Error ? query.error.message : "获取提示词失败");
}, [message, query.error, query.isError]);
const handleListScroll = (event: UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
if (query.hasNextPage && !query.isFetchingNextPage && target.scrollTop + target.clientHeight >= target.scrollHeight - 160) void query.fetchNextPage();
};
return (
<Modal title="提示词库" open={open} onCancel={() => onOpenChange(false)} footer={null} width={1040} centered>
<div data-canvas-no-zoom onWheelCapture={(event) => event.stopPropagation()}>
<div className="mx-auto max-w-2xl">
<Input size="large" prefix={<Search className="size-4 text-stone-400" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="按标题查询" />
</div>
<div className="mt-5 grid gap-3">
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptCategories.map((category) => (
<Tag.CheckableTag key={category} checked={selectedCategory === category} className={cn("prompt-filter-tag", selectedCategory === category && "is-active")} onChange={() => setSelectedCategory(category)}>
{category}
</Tag.CheckableTag>
))}
</div>
</div>
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400"></div>
<div className="flex flex-wrap gap-2">
{promptTags.map((tag) => {
const active = tag === ALL_PROMPTS_OPTION ? selectedTags.length === 0 : selectedTags.includes(tag);
return (
<Tag.CheckableTag key={tag} checked={active} className={cn("prompt-filter-tag", active && "is-active")} onChange={() => toggleTag(tag)}>
{tag}
</Tag.CheckableTag>
);
})}
</div>
</div>
</div>
<div className="thin-scrollbar mt-6 max-h-[520px] overflow-y-auto pr-2" data-canvas-no-zoom onScroll={handleListScroll} onWheelCapture={(event) => event.stopPropagation()}>
{query.isLoading ? <div className="flex h-40 items-center justify-center"><Spin /></div> : null}
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => (
<PromptCard key={item.id} item={item} onOpen={() => selectPrompt(item.prompt)} onCopy={() => selectPrompt(item.prompt)} actionLabel="使用此提示词" actionIcon={<Check className="size-3.5" />} actionType="primary" />
))}
</div>
{!query.isLoading && items.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到匹配的提示词" className="py-8" /> : null}
{query.isFetchingNextPage ? <div className="py-4 text-center"><Spin size="small" /></div> : null}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,26 @@
"use client";
import { useMemo } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { ALL_PROMPTS_OPTION, fetchPrompts } from "@/services/api/prompts";
export const PROMPT_PAGE_SIZE = 20;
export function usePromptList({ keyword, tags, category, enabled = true }: { keyword: string; tags: string[]; category: string; enabled?: boolean }) {
const query = useInfiniteQuery({
queryKey: ["prompts", keyword, tags, category],
queryFn: ({ pageParam }) => fetchPrompts({ keyword, tag: tags, category, page: pageParam, pageSize: PROMPT_PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => pages.reduce((total, page) => total + page.items.length, 0) < lastPage.total ? pages.length + 1 : undefined,
enabled,
});
const firstPage = query.data?.pages[0];
return {
query,
items: useMemo(() => query.data?.pages.flatMap((page) => page.items) || [], [query.data?.pages]),
tags: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.tags || [])], [firstPage?.tags]),
categories: useMemo(() => [ALL_PROMPTS_OPTION, ...(firstPage?.categories || [])], [firstPage?.categories]),
total: firstPage?.total || 0,
};
}
+18
View File
@@ -0,0 +1,18 @@
"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
@@ -0,0 +1,8 @@
"use client";
import { useSyncThemeClass } from "@/stores/use-theme-store";
export function ThemeSync() {
useSyncThemeClass();
return null;
}
@@ -0,0 +1,269 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
export type TransitionVariant =
| "circle"
| "square"
| "triangle"
| "diamond"
| "hexagon"
| "rectangle"
| "star"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
variant?: TransitionVariant
/** When true, the transition expands from the viewport center instead of the button center. */
fromCenter?: boolean
theme?: "light" | "dark"
targetTheme?: "light" | "dark"
onThemeChange?: (theme: "light" | "dark") => void
}
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
const pairs = Array.from(
{ length: vertexCount },
() => `${cx}px ${cy}px`
).join(", ")
return `polygon(${pairs})`
}
function getThemeTransitionClipPaths(
variant: TransitionVariant,
cx: number,
cy: number,
maxRadius: number,
viewportWidth: number,
viewportHeight: number
): [string, string] {
switch (variant) {
case "circle":
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
case "square": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const halfSide = Math.max(halfW, halfH) * 1.05
const end = [
`${cx - halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy - halfSide}px`,
`${cx + halfSide}px ${cy + halfSide}px`,
`${cx - halfSide}px ${cy + halfSide}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "triangle": {
const scale = maxRadius * 2.2
const dx = (Math.sqrt(3) / 2) * scale
const verts = [
`${cx}px ${cy - scale}px`,
`${cx + dx}px ${cy + 0.5 * scale}px`,
`${cx - dx}px ${cy + 0.5 * scale}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
}
case "diamond": {
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
const R = maxRadius * Math.SQRT2
const end = [
`${cx}px ${cy - R}px`,
`${cx + R}px ${cy}px`,
`${cx}px ${cy + R}px`,
`${cx - R}px ${cy}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "hexagon": {
const R = maxRadius * Math.SQRT2
const verts: string[] = []
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
}
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
}
case "rectangle": {
const halfW = Math.max(cx, viewportWidth - cx)
const halfH = Math.max(cy, viewportHeight - cy)
const end = [
`${cx - halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy - halfH}px`,
`${cx + halfW}px ${cy + halfH}px`,
`${cx - halfW}px ${cy + halfH}px`,
].join(", ")
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
}
case "star": {
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
const R = maxRadius * Math.SQRT2 * 1.03
const innerRatio = 0.42
const starPolygon = (radius: number) => {
const verts: string[] = []
for (let i = 0; i < 5; i++) {
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
verts.push(
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
)
const innerA = outerA + Math.PI / 5
verts.push(
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
)
}
return `polygon(${verts.join(", ")})`
}
const startR = Math.max(2, R * 0.025)
return [starPolygon(startR), starPolygon(R)]
}
default:
return [
`circle(0px at ${cx}px ${cy}px)`,
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
]
}
}
export const AnimatedThemeToggler = ({
children,
className,
duration = 400,
variant,
fromCenter = false,
theme,
targetTheme,
onThemeChange,
...props
}: AnimatedThemeTogglerProps) => {
const shape = variant ?? "circle"
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (theme) {
setIsDark(theme === "dark")
return
}
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [theme])
const toggleTheme = useCallback(() => {
const button = buttonRef.current
if (!button) return
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
let x: number
let y: number
if (fromCenter) {
x = viewportWidth / 2
y = viewportHeight / 2
} else {
const { top, left, width, height } = button.getBoundingClientRect()
x = left + width / 2
y = top + height / 2
}
const maxRadius = Math.hypot(
Math.max(x, viewportWidth - x),
Math.max(y, viewportHeight - y)
)
const applyTheme = () => {
const nextTheme = targetTheme ?? (isDark ? "light" : "dark")
if (nextTheme === (isDark ? "dark" : "light")) return
setIsDark(nextTheme === "dark")
document.documentElement.classList.toggle("dark", nextTheme === "dark")
document.documentElement.style.colorScheme = nextTheme
onThemeChange?.(nextTheme)
}
if (typeof document.startViewTransition !== "function") {
applyTheme()
return
}
const clipPath = getThemeTransitionClipPaths(
shape,
x,
y,
maxRadius,
viewportWidth,
viewportHeight
)
const root = document.documentElement
root.dataset.magicuiThemeVt = "active"
root.style.setProperty(
"--magicui-theme-toggle-vt-duration",
`${duration}ms`
)
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
// theme unclipped between snapshot and the ready.then() JS animation.
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
const cleanup = () => {
delete root.dataset.magicuiThemeVt
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
root.style.removeProperty("--magicui-theme-vt-clip-from")
}
const transition = document.startViewTransition(() => {
flushSync(applyTheme)
})
if (typeof transition?.finished?.finally === "function") {
transition.finished.finally(cleanup)
} else {
cleanup()
}
const ready = transition?.ready
if (ready && typeof ready.then === "function") {
ready.then(() => {
document.documentElement.animate(
{
clipPath,
},
{
duration,
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
easing: shape === "star" ? "linear" : "ease-in-out",
fill: "forwards",
pseudoElement: "::view-transition-new(root)",
}
)
})
}
}, [shape, fromCenter, duration, isDark, targetTheme, onThemeChange])
return (
<button
type="button"
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{children ?? (isDark ? <Sun /> : <Moon />)}
<span className="sr-only">{props["aria-label"] || "切换主题"}</span>
</button>
)
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
animate,
motion,
useInView,
useMotionValue,
useReducedMotion,
useTransform,
type HTMLMotionProps,
} from "motion/react"
import { cn } from "@/lib/utils"
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
const BAND_HALF = 17
const SWEEP_START = -BAND_HALF
const SWEEP_END = 100 + BAND_HALF
const sweepEase = (t: number) =>
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
function buildGradient(pos: number, colors: string[], textColor: string) {
const bandStart = pos - BAND_HALF
const bandEnd = pos + BAND_HALF
if (bandStart >= 100) {
return `linear-gradient(90deg, ${textColor}, ${textColor})`
}
const n = colors.length
const parts: string[] = []
if (bandStart > 0)
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
colors.forEach((c, i) => {
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
parts.push(`${c} ${pct.toFixed(2)}%`)
})
if (bandEnd < 100)
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
return `linear-gradient(90deg, ${parts.join(", ")})`
}
function measureWidths(el: HTMLElement, texts: string[]) {
const ghost = el.cloneNode() as HTMLElement
Object.assign(ghost.style, {
position: "absolute",
visibility: "hidden",
pointerEvents: "none",
width: "auto",
whiteSpace: "nowrap",
})
el.parentElement!.appendChild(ghost)
const widths = texts.map((t) => {
ghost.textContent = t
return ghost.getBoundingClientRect().width
})
ghost.remove()
return widths
}
/**
* Props for {@link DiaTextReveal}.
*/
export interface DiaTextRevealProps extends Omit<
HTMLMotionProps<"span">,
"ref" | "children" | "style" | "animate" | "transition" | "color"
> {
/**
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
*/
text: string | string[]
/**
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
*/
colors?: string[]
/**
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
* @defaultValue `"var(--foreground)"`
*/
textColor?: string
/**
* Duration of one sweep pass, in seconds.
* @defaultValue `1.5`
*/
duration?: number
/**
* Delay before the sweep starts, in seconds.
* @defaultValue `0`
*/
delay?: number
/**
* When `text` is an array, replay the sweep and advance to the next string after each completion.
* @defaultValue `false`
*/
repeat?: boolean
/**
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
* @defaultValue `0.5`
*/
repeatDelay?: number
/**
* If `true`, the animation starts only after the element enters the viewport.
* @defaultValue `true`
*/
startOnView?: boolean
/**
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
* @defaultValue `true`
*/
once?: boolean
/**
* Additional class names for the animated `span` (e.g. typography utilities).
*/
className?: string
/**
* When `text` has multiple entries, use the widest strings width for layout instead of animating width per line.
* @defaultValue `false`
*/
fixedWidth?: boolean
}
export function DiaTextReveal({
text,
colors = DEFAULT_COLORS,
textColor = "var(--foreground)",
duration = 1.5,
delay = 0,
repeat = false,
repeatDelay = 0.5,
startOnView = true,
once = true,
className,
fixedWidth = false,
...props
}: DiaTextRevealProps) {
const texts = Array.isArray(text) ? text : [text]
const isMulti = texts.length > 1
const prefersReducedMotion = useReducedMotion()
const spanRef = useRef<HTMLSpanElement>(null)
const optsRef = useRef({
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
})
optsRef.current = {
colors,
textColor,
duration,
delay,
repeat,
repeatDelay,
texts,
}
const indexRef = useRef(0)
const hasPlayedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const playRef = useRef<() => void>(null!)
const stopRef = useRef<(() => void) | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
const sweepPos = useMotionValue(SWEEP_START)
const backgroundImage = useTransform(sweepPos, (pos) =>
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
)
const isInView = useInView(spanRef, { once, amount: 0.1 })
useEffect(() => {
const el = spanRef.current
if (!el || !isMulti) return
setMeasuredWidths(measureWidths(el, texts))
}, [Array.isArray(text) ? text.join("\0") : text])
playRef.current = () => {
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
sweepPos.set(SWEEP_START)
const controls = animate(sweepPos, SWEEP_END, {
duration,
delay,
ease: sweepEase,
onComplete() {
if (!repeat) return
timerRef.current = setTimeout(() => {
const next = (indexRef.current + 1) % texts.length
indexRef.current = next
setActiveIndex(next)
playRef.current()
}, repeatDelay * 1000)
},
})
stopRef.current = () => controls.stop()
}
useEffect(() => {
if (prefersReducedMotion) {
sweepPos.set(SWEEP_END)
return
}
if (startOnView && !isInView) return
if (once && hasPlayedRef.current) return
hasPlayedRef.current = true
playRef.current()
return () => {
stopRef.current?.()
clearTimeout(timerRef.current)
}
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
const fixedW =
isMulti && fixedWidth && measuredWidths.length > 0
? Math.max(...measuredWidths)
: undefined
const animatedW =
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
? measuredWidths[activeIndex]
: undefined
return (
<motion.span
ref={spanRef}
className={cn("align-bottom leading-[100%] text-inherit", className)}
style={{
transform: "translateY(-2px)",
color: "transparent",
backgroundClip: "text",
WebkitBackgroundClip: "text",
backgroundSize: "100% 100%",
backgroundImage,
...(isMulti && {
display: "inline-block",
overflow: "hidden",
whiteSpace: "nowrap",
verticalAlign: "text-center",
...(fixedW != null && { width: fixedW }),
}),
}}
animate={animatedW != null ? { width: animatedW } : undefined}
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
{...props}
>
{texts[activeIndex]}
</motion.span>
)
}
+15
View File
@@ -0,0 +1,15 @@
"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;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import type { CSSProperties, ReactNode, RefObject } from "react";
import { Dropdown } from "antd";
import { Settings2 } from "lucide-react";
import type { ItemType } from "antd/es/menu/interface";
import { GitHubLink } from "@/components/github-link";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { cn } from "@/lib/utils";
import type { ThemeName } from "@/stores/use-theme-store";
type UserStatusActionsProps = {
version: string;
theme: ThemeName;
onThemeChange: (theme: ThemeName) => void;
onOpenConfig?: () => void;
showConfig?: boolean;
userName?: string;
initial?: string;
menuItems: ItemType[];
accountOpen?: boolean;
onAccountOpenChange?: (open: boolean) => void;
accountRef?: RefObject<HTMLDivElement | null>;
getPopupContainer?: (node: HTMLElement) => HTMLElement;
avatarClassName?: string;
avatarStyle?: CSSProperties;
gitHubClassName?: string;
gitHubStyle?: CSSProperties;
userLabel?: ReactNode;
iconStyle?: CSSProperties;
};
export function UserStatusActions({
version,
theme,
onThemeChange,
onOpenConfig,
showConfig = true,
userName,
initial,
menuItems,
accountOpen,
onAccountOpenChange,
accountRef,
getPopupContainer,
avatarClassName,
avatarStyle,
gitHubClassName,
gitHubStyle,
userLabel,
iconStyle,
}: UserStatusActionsProps) {
const avatarText = initial || (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";
return (
<div className="inline-flex shrink-0 items-center gap-1.5">
{showConfig ? (
<button
type="button"
className={naturalIconClass}
style={iconStyle}
onClick={onOpenConfig}
aria-label="配置"
title="配置"
>
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler
theme={theme}
onThemeChange={onThemeChange}
className={naturalIconClass}
style={iconStyle}
aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"}
/>
<span className="shrink-0 text-xs font-medium text-stone-500 dark:text-stone-400">{version}</span>
<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={cn("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", avatarClassName)}
style={avatarStyle}
aria-label="账户菜单"
>
<span className="leading-none">{userLabel ?? avatarText}</span>
</button>
</Dropdown>
</div>
</div>
);
}