style(code): 格式化代码缩进和布局样式
- 统一调整 admin.ts 文件中的接口定义缩进格式 - 优化 animated-theme-toggler.tsx 组件的代码结构和缩进 - 规范 app-config-modal.tsx 中 JSX 元素的嵌套格式 - 整理 app-providers.tsx 中的组件层级缩进 - 标准化 app-theme.ts 中的对象属性缩进格式
This commit is contained in:
@@ -9,39 +9,39 @@ 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);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
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);
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
.dropdown :global(.ant-select-item) {
|
||||
font-weight: 400;
|
||||
min-height: 34px;
|
||||
padding-inline: 10px 28px;
|
||||
font-weight: 400;
|
||||
min-height: 34px;
|
||||
padding-inline: 10px 28px;
|
||||
}
|
||||
|
||||
.dropdown :global(.ant-select-item-option-selected) {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-holder) {
|
||||
scrollbar-width: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar,
|
||||
.dropdown :global(.rc-virtual-list-holder)::-webkit-scrollbar-button {
|
||||
height: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-scrollbar) {
|
||||
right: 4px !important;
|
||||
width: 2px !important;
|
||||
right: 4px !important;
|
||||
width: 2px !important;
|
||||
}
|
||||
|
||||
.dropdown :global(.rc-virtual-list-scrollbar-thumb) {
|
||||
background: rgba(120, 113, 108, 0.42) !important;
|
||||
border-radius: 999px !important;
|
||||
background: rgba(120, 113, 108, 0.42) !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
|
||||
@@ -6,51 +6,64 @@ import styles from "./model-picker.module.css";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
value?: string;
|
||||
onChange: (model: string) => void;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
placeholder?: string;
|
||||
onMissingConfig?: () => void;
|
||||
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: <ModelLabel model={model} /> }));
|
||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
||||
const options = Array.from(new Set([value, ...config.models].filter(Boolean))).map((model) => ({ value: model, label: <ModelLabel model={model} /> }));
|
||||
const width = fullWidth ? "100%" : `min(${Math.max(156, (value || placeholder).length * 8 + 64)}px, 100%)`;
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
className={`canvas-control-select ${className || ""}`}
|
||||
classNames={{ popup: { root: styles.dropdown } }}
|
||||
popupMatchSelectWidth
|
||||
popupRender={(menu) => <div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>{menu}</div>}
|
||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
||||
value={value || undefined}
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
notFoundContent="请先到配置里拉取模型列表"
|
||||
onChange={onChange}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (!options.length) onMissingConfig?.();
|
||||
}}
|
||||
filterOption={(input, option) => String(option?.value || "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
className={`canvas-control-select ${className || ""}`}
|
||||
classNames={{ popup: { root: styles.dropdown } }}
|
||||
popupMatchSelectWidth
|
||||
popupRender={(menu) => (
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
style={{ width, maxWidth: "100%", minWidth: 0, flexShrink: 1 }}
|
||||
value={value || undefined}
|
||||
placeholder={placeholder}
|
||||
options={options}
|
||||
notFoundContent="请先到配置里拉取模型列表"
|
||||
onChange={onChange}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={() => {
|
||||
if (!options.length) onMissingConfig?.();
|
||||
}}
|
||||
filterOption={(input, option) =>
|
||||
String(option?.value || "")
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelLabel({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(model);
|
||||
return <span className="flex min-w-0 items-center gap-2">{icon && <img src={icon} alt="" className="size-4 shrink-0" />}<span className="truncate">{model}</span></span>;
|
||||
const icon = resolveModelIcon(model);
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{icon && <img src={icon} alt="" className="size-4 shrink-0" />}
|
||||
<span className="truncate">{model}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelIcon(model: string) {
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||
return "";
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
if (name.includes("gemini") || name.includes("google")) return "/icons/gemini.svg";
|
||||
if (name.includes("gpt") || name.includes("openai")) return "/icons/openai.svg";
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -6,30 +6,56 @@ 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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,29 +6,43 @@ 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>
|
||||
</>
|
||||
);
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,71 +10,79 @@ 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);
|
||||
};
|
||||
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]);
|
||||
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();
|
||||
};
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@ 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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,269 +1,194 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
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
|
||||
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})`
|
||||
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`
|
||||
)
|
||||
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})`];
|
||||
}
|
||||
return `polygon(${verts.join(", ")})`
|
||||
}
|
||||
const startR = Math.max(2, R * 0.025)
|
||||
return [starPolygon(startR), starPolygon(R)]
|
||||
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)`];
|
||||
}
|
||||
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)
|
||||
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
|
||||
}
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setIsDark(theme === "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
updateTheme()
|
||||
updateTheme();
|
||||
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
const observer = new MutationObserver(updateTheme);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [theme])
|
||||
return () => observer.disconnect();
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) return;
|
||||
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
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
|
||||
}
|
||||
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 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
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 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 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])
|
||||
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,263 +1,228 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type HTMLMotionProps } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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 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
|
||||
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
|
||||
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 >= 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)}%`)
|
||||
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)}%`)
|
||||
})
|
||||
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%`)
|
||||
if (bandEnd < 100) parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`);
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
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
|
||||
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 string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
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 string’s 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()
|
||||
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 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 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 [activeIndex, setActiveIndex] = useState(0);
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([]);
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
const sweepPos = useMotionValue(SWEEP_START);
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
const backgroundImage = useTransform(sweepPos, (pos) => buildGradient(pos, optsRef.current.colors, optsRef.current.textColor));
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
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])
|
||||
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
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current;
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
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)
|
||||
},
|
||||
})
|
||||
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()
|
||||
}
|
||||
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()
|
||||
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])
|
||||
return () => {
|
||||
stopRef.current?.();
|
||||
clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos]);
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
const fixedW = isMulti && fixedWidth && measuredWidths.length > 0 ? Math.max(...measuredWidths) : undefined;
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: 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>
|
||||
)
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user