ai-chatbot/components/style-pro/StyleProWorkspace.tsx
Your Name a188ee963a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: share brand top bar with style pro
2026-05-16 20:04:59 +08:00

773 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
Clock3,
Loader2,
RotateCcw,
Sparkles,
Upload,
UserRound,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import {
BrandTopBar,
BrandTopBarButton,
BrandTopBarIconButton,
} from "@/components/layout/brand-top-bar";
import { Button } from "@/components/ui/button";
import { imageGenerationService } from "@/services/canvas";
import { cn, fetcher } from "@/lib/utils";
import type {
PublicStyleProCategory,
PublicStyleProConfig,
StyleProHistoryItem,
StyleProPersistedState,
StyleProSelectionMap,
} from "@/lib/style-pro/config";
type BrandingSettings = {
siteName?: string;
siteLogo?: string | null;
siteLogoDark?: string | null;
};
type StyleProConfigResponse = {
success: boolean;
config?: PublicStyleProConfig;
error?: string;
};
type StyleProGenerateResponse = {
success: boolean;
error?: string;
data?: {
imageUrl: string;
thumbnailUrl?: string;
model?: string;
modelRef?: string;
selections: StyleProSelectionMap;
optionLabels: string[];
};
};
type StyleProWorkspaceProps = {
canvasId: string;
initialSourceImageUrl?: string | null;
persistedState?: StyleProPersistedState | null;
onAddToCanvas?: (item: StyleProHistoryItem) => void | Promise<void>;
onBack?: () => void;
onPersistState?: (state: StyleProPersistedState) => void | Promise<void>;
};
function getHistoryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `style-pro-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function firstEnabledOptionId(category: PublicStyleProCategory) {
return category.options.find((option) => option.enabled)?.id || "";
}
function loadImageDimensions(url: string) {
return new Promise<{ width?: number; height?: number }>((resolve) => {
if (typeof window === "undefined" || !url) {
resolve({});
return;
}
const image = new window.Image();
image.onload = () => {
resolve({
width: image.naturalWidth || undefined,
height: image.naturalHeight || undefined,
});
};
image.onerror = () => resolve({});
image.src = url;
});
}
function normalizeHistory(history: StyleProHistoryItem[] | undefined) {
return Array.isArray(history) ? history.slice(0, 4) : [];
}
function ImagePreview({
alt,
className,
imageUrl,
}: {
alt: string;
className?: string;
imageUrl?: string | null;
}) {
if (!imageUrl) {
return (
<div
className={cn(
"flex h-full min-h-[220px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400 text-sm",
className
)}
>
等待图片
</div>
);
}
return (
<img
alt={alt}
className={cn("h-full w-full rounded-lg object-contain", className)}
draggable={false}
src={imageUrl}
/>
);
}
function GeneratingImagePlaceholder() {
return (
<div className="min-h-[460px] rounded-lg border border-white/10 bg-white p-5 text-neutral-800">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-[15px] text-neutral-700">
正在创建图片
</div>
<div className="mt-1 text-xs text-neutral-400">
正在理解图片与提示词组合
</div>
</div>
<span className="inline-flex items-center gap-2 rounded-lg bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-500">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
生成中
</span>
</div>
<div className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-lg bg-[#f4f4f4]">
<div className="style-pro-dot-field absolute inset-0" />
<div className="style-pro-light-field absolute inset-0" />
<div className="style-pro-scan-field absolute inset-0" />
</div>
<style>{`
@keyframes styleProDotDrift {
0% { transform: translate3d(-10px, -8px, 0); opacity: 0.35; }
45% { transform: translate3d(8px, 18px, 0); opacity: 0.78; }
100% { transform: translate3d(18px, -4px, 0); opacity: 0.42; }
}
@keyframes styleProLightFloat {
0% { transform: translate3d(-18%, -10%, 0) scale(1); opacity: 0.72; }
50% { transform: translate3d(18%, 16%, 0) scale(1.06); opacity: 0.98; }
100% { transform: translate3d(34%, -4%, 0) scale(1); opacity: 0.74; }
}
@keyframes styleProScanMove {
0% { transform: translateX(-80%); opacity: 0; }
18% { opacity: 0.65; }
54% { opacity: 0.82; }
100% { transform: translateX(80%); opacity: 0; }
}
.style-pro-generating-frame {
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025), 0 18px 50px rgba(0,0,0,0.08);
}
.style-pro-dot-field {
background-image: radial-gradient(circle, rgba(116,116,116,0.32) 1.05px, transparent 1.05px);
background-size: 16px 16px;
mask-image: radial-gradient(ellipse 88% 72% at 50% 44%, #000 0%, rgba(0,0,0,0.78) 48%, transparent 82%);
animation: styleProDotDrift 3.8s ease-in-out infinite alternate;
}
.style-pro-light-field {
background:
radial-gradient(circle at 24% 24%, rgba(255,255,255,0.96) 0%, rgba(255,255,255,0.82) 22%, transparent 45%),
radial-gradient(circle at 70% 72%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 20%, transparent 44%);
animation: styleProLightFloat 4.4s ease-in-out infinite alternate;
}
.style-pro-scan-field {
width: 150%;
left: -25%;
background: linear-gradient(105deg, transparent 20%, rgba(255,255,255,0.92) 48%, transparent 74%);
animation: styleProScanMove 3.4s ease-in-out infinite;
}
`}</style>
</div>
);
}
function BeforeAfterSlider({
afterUrl,
beforeUrl,
generating = false,
}: {
afterUrl?: string | null;
beforeUrl?: string | null;
generating?: boolean;
}) {
const [position, setPosition] = useState(50);
if (generating) {
return <GeneratingImagePlaceholder />;
}
if (!beforeUrl && !afterUrl) {
return (
<div className="flex min-h-[460px] items-center justify-center rounded-lg border border-dashed border-gray-200 bg-gray-50 text-gray-400">
上传图片后开始生成
</div>
);
}
if (!afterUrl) {
return (
<div className="min-h-[460px] rounded-lg border border-gray-200 bg-gray-50 p-3">
<ImagePreview alt="上传图片" imageUrl={beforeUrl} />
</div>
);
}
return (
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
<ImagePreview
alt="生成后"
className="absolute inset-0 h-full w-full"
imageUrl={afterUrl}
/>
<div
className="absolute inset-0 overflow-hidden"
style={{ clipPath: `inset(0 ${100 - position}% 0 0)` }}
>
<ImagePreview
alt="生成前"
className="h-full w-full"
imageUrl={beforeUrl}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white"
style={{ left: `${position}%` }}
>
<span className="-translate-x-1/2 absolute top-1/2 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/70 bg-black/70 text-[11px] font-semibold text-white shadow-lg">
对比
</span>
</div>
<input
aria-label="图像对比滑块"
className="absolute inset-x-4 bottom-5 z-10 accent-emerald-400"
max={100}
min={0}
onChange={(event) => setPosition(Number(event.target.value))}
type="range"
value={position}
/>
<div className="absolute left-4 top-4 rounded-full bg-black/65 px-3 py-1 text-xs text-white/80">
原图
</div>
<div className="absolute right-4 top-4 rounded-full bg-emerald-500/90 px-3 py-1 text-xs font-semibold text-black">
结果
</div>
</div>
);
}
export function StyleProWorkspace({
canvasId,
initialSourceImageUrl,
onAddToCanvas,
onPersistState,
persistedState,
}: StyleProWorkspaceProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const { data: brandingData } = useSWR<BrandingSettings>(
"/api/site-settings?scope=branding",
fetcher
);
const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
const config = configData?.config;
const enabledCategories = useMemo(
() =>
(config?.categories || [])
.filter((category) => category.enabled)
.map((category) => ({
...category,
options: category.options.filter((option) => option.enabled),
}))
.filter((category) => category.options.length > 0),
[config?.categories]
);
const [sourceImageUrl, setSourceImageUrl] = useState(
persistedState?.sourceImageUrl || initialSourceImageUrl || ""
);
const [currentResult, setCurrentResult] =
useState<StyleProHistoryItem | null>(
normalizeHistory(persistedState?.history)[0] || null
);
const [history, setHistory] = useState<StyleProHistoryItem[]>(
normalizeHistory(persistedState?.history)
);
const [selections, setSelections] = useState<StyleProSelectionMap>({});
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
useEffect(() => {
const nextSource =
persistedState?.sourceImageUrl || initialSourceImageUrl || "";
setSourceImageUrl((current) => current || nextSource);
const nextHistory = normalizeHistory(persistedState?.history);
setHistory(nextHistory);
setCurrentResult((current) => current || nextHistory[0] || null);
}, [initialSourceImageUrl, persistedState?.history, persistedState?.sourceImageUrl]);
const siteName = brandingData?.siteName || "由壹点AI";
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
const selectedOptionLabels = useMemo(() => {
const labels: string[] = [];
for (const category of enabledCategories) {
const selected = selections[category.id] || [];
for (const optionId of selected) {
const option = category.options.find((item) => item.id === optionId);
if (option) {
labels.push(`${category.title}:${option.label}`);
}
}
}
return labels;
}, [enabledCategories, selections]);
const missingRequiredCategories = useMemo(
() =>
enabledCategories.filter((category) => {
if (!category.required && category.minSelections <= 0) return false;
const selectedCount = selections[category.id]?.length || 0;
return (
selectedCount <
Math.max(category.required ? 1 : 0, category.minSelections)
);
}),
[enabledCategories, selections]
);
const persistState = useCallback(
async (nextState: StyleProPersistedState) => {
await onPersistState?.({
sourceImageUrl: nextState.sourceImageUrl,
history: normalizeHistory(nextState.history),
});
},
[onPersistState]
);
const updateSourceImage = useCallback(
async (url: string) => {
const nextUrl = String(url || "").trim();
setSourceImageUrl(nextUrl);
setCurrentResult(null);
await persistState({ sourceImageUrl: nextUrl, history });
},
[history, persistState]
);
const handleFile = useCallback(
async (file: File | null | undefined) => {
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("请上传图片文件");
return;
}
setUploading(true);
try {
const result = await imageGenerationService.uploadImage(file);
if (!result.success || !result.data?.url) {
throw new Error(result.error || "上传失败");
}
await updateSourceImage(result.data.url);
toast.success("图片已上传");
} catch (error) {
toast.error(error instanceof Error ? error.message : "上传失败");
} finally {
setUploading(false);
}
},
[updateSourceImage]
);
const toggleOption = useCallback(
(category: PublicStyleProCategory, optionId: string) => {
setSelections((current) => {
const previous = current[category.id] || [];
const selected = previous.includes(optionId);
let nextSelected = selected
? previous.filter((id) => id !== optionId)
: [...previous, optionId];
if (!selected && category.maxSelections <= 1) {
nextSelected = [optionId];
}
if (nextSelected.length > category.maxSelections) {
nextSelected = nextSelected.slice(-category.maxSelections);
}
return {
...current,
[category.id]: nextSelected,
};
});
},
[]
);
const quickFillRequired = useCallback(() => {
setSelections((current) => {
const next = { ...current };
for (const category of enabledCategories) {
if (!category.required || next[category.id]?.length) continue;
const firstId = firstEnabledOptionId(category);
if (firstId) next[category.id] = [firstId];
}
return next;
});
}, [enabledCategories]);
const handleGenerate = useCallback(async () => {
if (!sourceImageUrl) {
toast.error("请先上传一张图片");
return;
}
if (!config?.enabled) {
toast.error("风格化 PRO 暂未启用");
return;
}
if (missingRequiredCategories.length > 0) {
toast.error(`请完成必选项:${missingRequiredCategories[0].title}`);
return;
}
setGenerating(true);
try {
const response = await fetch("/api/style-pro/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
canvasId,
sourceImageUrl,
selections,
}),
});
const payload = (await response.json().catch(() => ({}))) as
| StyleProGenerateResponse
| undefined;
if (!response.ok || !payload?.success || !payload.data?.imageUrl) {
throw new Error(payload?.error || "生成失败");
}
const dimensions = await loadImageDimensions(payload.data.imageUrl);
const item: StyleProHistoryItem = {
id: getHistoryId(),
sourceImageUrl,
resultImageUrl: payload.data.imageUrl,
selections: payload.data.selections || selections,
optionLabels: payload.data.optionLabels || selectedOptionLabels,
createdAt: new Date().toISOString(),
model: payload.data.model,
modelRef: payload.data.modelRef,
width: dimensions.width,
height: dimensions.height,
};
const nextHistory = [item, ...history.filter((entry) => entry.id !== item.id)].slice(
0,
4
);
setHistory(nextHistory);
setCurrentResult(item);
await persistState({ sourceImageUrl, history: nextHistory });
toast.success("风格化 PRO 生成完成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成失败");
} finally {
setGenerating(false);
}
}, [
canvasId,
config?.enabled,
history,
missingRequiredCategories,
persistState,
selectedOptionLabels,
selections,
sourceImageUrl,
]);
const restoreHistoryItem = useCallback((item: StyleProHistoryItem) => {
setSourceImageUrl(item.sourceImageUrl);
setSelections(item.selections || {});
setCurrentResult(item);
}, []);
const disabledReason = !sourceImageUrl
? "请先上传图片"
: missingRequiredCategories.length > 0
? `还差:${missingRequiredCategories[0].title}`
: "";
return (
<div className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]">
<style>{`
.style-pro-index-shell .section-card {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 24px;
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.05);
}
.style-pro-index-shell .subsection-title {
color: #2563eb;
font-size: 13px;
font-weight: 650;
white-space: nowrap;
}
.style-pro-index-shell .option-btn {
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
color: rgba(17, 24, 39, 0.78);
transition: all 0.18s ease;
}
.style-pro-index-shell .option-btn:hover {
background: #1c1c1e;
border-color: #1c1c1e;
color: #fff;
}
.style-pro-index-shell .option-btn.active {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(37, 99, 235, 0.05));
color: #2563eb;
border-color: #60a5fa;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
`}</style>
<main className="min-w-0 flex-1 overflow-y-auto">
<BrandTopBar
logo={logo}
onLogoClick={() => {
window.location.href = "/";
}}
position="sticky"
siteName={siteName}
>
<BrandTopBarButton onClick={quickFillRequired}>
<UserRound className="h-3.5 w-3.5" />
执行者
</BrandTopBarButton>
<BrandTopBarButton
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
variant="primary"
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
执行
</BrandTopBarButton>
<BrandTopBarButton
onClick={() => {
setSelections({});
setCurrentResult(null);
}}
>
<RotateCcw className="h-3.5 w-3.5" />
重置
</BrandTopBarButton>
<BrandTopBarIconButton
aria-label="个人中心"
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
title="个人中心"
>
<UserRound className="h-3.5 w-3.5" />
</BrandTopBarIconButton>
</BrandTopBar>
<div className="mx-auto max-w-[1920px] space-y-5 px-6 py-6">
<section className="section-card p-6">
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="subsection-title">上传图片</span>
<span className="text-[12px] text-black/40">支持拖拽上传</span>
</div>
<button
className="flex min-h-[260px] w-full items-center justify-center overflow-hidden rounded-2xl border border-dashed border-black/15 bg-white transition hover:border-black/35"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
void handleFile(event.dataTransfer.files?.[0]);
}}
type="button"
>
{sourceImageUrl ? (
<img
alt="上传图片"
className="h-full max-h-[360px] w-full object-contain"
src={sourceImageUrl}
/>
) : (
<span className="flex flex-col items-center gap-3 text-gray-500">
{uploading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : (
<Upload className="h-6 w-6" />
)}
<span className="text-[13px]">点击上传,或拖入图片</span>
</span>
)}
</button>
<input
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/>
</div>
</section>
{configLoading ? (
<section className="section-card flex min-h-[180px] items-center justify-center p-6 text-[13px] text-black/45">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载配置
</section>
) : !config?.enabled ? (
<section className="section-card border-amber-200 bg-amber-50 p-6 text-amber-800">
风格化 PRO 暂未启用,请联系管理员开启。
</section>
) : (
enabledCategories.map((category) => {
const selectedIds = selections[category.id] || [];
return (
<section className="section-card p-6" key={category.id}>
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-3 flex items-start gap-4">
<span className="subsection-title mt-2 w-20">
{category.title}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap gap-2">
{category.options.map((option) => {
const selected = selectedIds.includes(option.id);
return (
<button
className={cn(
"option-btn rounded-full px-4 py-2 text-[14px] font-semibold",
selected && "active"
)}
key={option.id}
onClick={() => toggleOption(category, option.id)}
title={option.description || option.label}
type="button"
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
</div>
</section>
);
})
)}
<section className="section-card p-6">
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<span className="subsection-title">生图结果</span>
<p className="mt-1 text-[13px] text-black/45">
生成后拖动滑块查看原图与结果图。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
className="h-8 rounded-[14px] bg-[#1c1c1e] px-4 text-[12px] text-white hover:bg-black"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
>
{generating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{generating ? "正在创建图片" : "执行"}
</Button>
<Button
className="h-8 rounded-[14px] border-black/10 bg-white px-4 text-[12px] text-black/70 shadow-sm hover:bg-black/5"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
variant="outline"
>
添加到画布
</Button>
</div>
</div>
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
/>
</div>
<div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="subsection-title">历史记录</span>
<span className="text-[12px] text-black/40">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
{history.length > 0 ? (
history.map((item) => (
<button
className={cn(
"overflow-hidden rounded-2xl border bg-white text-left transition hover:border-black/35",
currentResult?.id === item.id
? "border-black/30 shadow-[0_4px_12px_rgba(15,23,42,0.12)]"
: "border-black/10"
)}
key={item.id}
onClick={() => restoreHistoryItem(item)}
type="button"
>
<div className="aspect-square bg-gray-100">
<img
alt="风格化历史"
className="h-full w-full object-cover"
src={item.resultImageUrl}
/>
</div>
<div className="space-y-1 p-2">
<div className="flex items-center gap-1 text-[11px] text-gray-400">
<Clock3 className="h-3 w-3" />
{new Date(item.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
<div className="line-clamp-1 text-[12px] text-black/70">
{item.optionLabels[0] || "风格化结果"}
</div>
</div>
</button>
))
) : (
<div className="col-span-2 rounded-2xl border border-dashed border-black/15 bg-white p-5 text-center text-[13px] text-black/40">
生成后会在这里保存历史。
</div>
)}
</div>
</div>
</div>
</section>
</div>
</main>
</div>
);
}