feat: share brand top bar with style pro
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-16 20:04:59 +08:00
parent fa2f26bf97
commit a188ee963a
3 changed files with 309 additions and 387 deletions

View File

@ -72,6 +72,7 @@ import {
normalizeLovartHomeSidebarConfig, normalizeLovartHomeSidebarConfig,
type LovartHomeSidebarItemKey, type LovartHomeSidebarItemKey,
} from "@/lib/home/lovart-sidebar-config"; } from "@/lib/home/lovart-sidebar-config";
import { BrandTopBar } from "@/components/layout/brand-top-bar";
type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator; type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator;
@ -1436,30 +1437,12 @@ export function LovartHome({
return ( return (
<div className="min-h-svh bg-[#f4f4f4] text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer"> <div className="min-h-svh bg-[#f4f4f4] text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
<header className="fixed left-0 top-0 z-40 h-[68px] w-full bg-[#f4f4f4]/90 backdrop-blur dark:bg-[#0a0a0c]/90"> <BrandTopBar
<div className="flex h-full items-center justify-between px-7"> logo={displayLogo}
<button onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
type="button" shouldInvertLogo={shouldInvertLogo}
className="flex items-center gap-2" siteName={siteName || "Lovart"}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} >
>
{displayLogo ? (
<img
src={displayLogo}
alt={siteName}
className={`h-8 w-8 rounded-full object-cover ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-4 w-4" />
</div>
)}
<span className="text-[20px] font-semibold leading-none tracking-tight dark:text-zinc-100">
{siteName || "Lovart"}
</span>
</button>
<div className="flex items-center gap-2">
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10"> <button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10">
<Bell className="h-3.5 w-3.5" /> <Bell className="h-3.5 w-3.5" />
</button> </button>
@ -1705,9 +1688,7 @@ export function LovartHome({
登录 登录
</button> </button>
)} )}
</div> </BrandTopBar>
</div>
</header>
<aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex"> <aside className="fixed left-5 top-[88px] z-30 hidden flex-col items-center gap-3 lg:flex">
<TooltipProvider delayDuration={120}> <TooltipProvider delayDuration={120}>

View File

@ -0,0 +1,122 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
type BrandTopBarProps = {
actions?: ReactNode;
children?: ReactNode;
className?: string;
contentClassName?: string;
logo?: string | null;
onLogoClick?: () => void;
position?: "fixed" | "sticky" | "static";
shouldInvertLogo?: boolean;
siteName: string;
};
export function BrandTopBar({
actions,
children,
className,
contentClassName,
logo,
onLogoClick,
position = "fixed",
shouldInvertLogo = false,
siteName,
}: BrandTopBarProps) {
const resolvedSiteName = String(siteName || "").trim() || "由壹点AI";
const positionClass =
position === "fixed"
? "fixed left-0 top-0 z-40 w-full"
: position === "sticky"
? "sticky top-0 z-40"
: "relative z-40";
return (
<header
className={cn(
positionClass,
"h-[68px] bg-[#f4f4f4]/90 backdrop-blur dark:bg-[#0a0a0c]/90",
className
)}
>
<div
className={cn(
"flex h-full items-center justify-between px-7",
contentClassName
)}
>
<button
className="flex items-center gap-2 transition-opacity hover:opacity-80"
onClick={onLogoClick}
type="button"
>
{logo ? (
<img
alt={resolvedSiteName}
className={cn(
"h-8 w-8 rounded-full object-cover",
shouldInvertLogo && "brightness-0 invert"
)}
src={logo}
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-4 w-4" />
</div>
)}
<span className="text-[20px] font-semibold leading-none tracking-tight dark:text-zinc-100">
{resolvedSiteName}
</span>
</button>
<div className="flex items-center gap-2">{actions || children}</div>
</div>
</header>
);
}
type BrandTopBarButtonProps = ComponentProps<"button"> & {
variant?: "default" | "primary";
};
export function BrandTopBarButton({
className,
type = "button",
variant = "default",
...props
}: BrandTopBarButtonProps) {
return (
<button
className={cn(
"inline-flex h-8 items-center justify-center gap-1.5 rounded-[14px] px-3 text-[12px] font-medium shadow-sm transition disabled:cursor-not-allowed disabled:opacity-45",
variant === "primary"
? "bg-[#1c1c1e] text-white hover:bg-black"
: "border border-black/10 bg-white text-black/70 hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white",
className
)}
type={type}
{...props}
/>
);
}
export function BrandTopBarIconButton({
className,
type = "button",
...props
}: ComponentProps<"button">) {
return (
<button
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white text-black/65 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white",
className
)}
type={type}
{...props}
/>
);
}

View File

@ -1,10 +1,7 @@
"use client"; "use client";
import { import {
ArrowLeft,
Clock3, Clock3,
Home,
ImagePlus,
Loader2, Loader2,
RotateCcw, RotateCcw,
Sparkles, Sparkles,
@ -14,8 +11,12 @@ import {
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
import { toast } from "sonner"; import { toast } from "sonner";
import {
BrandTopBar,
BrandTopBarButton,
BrandTopBarIconButton,
} from "@/components/layout/brand-top-bar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { imageGenerationService } from "@/services/canvas"; import { imageGenerationService } from "@/services/canvas";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import type { import type {
@ -269,7 +270,6 @@ export function StyleProWorkspace({
canvasId, canvasId,
initialSourceImageUrl, initialSourceImageUrl,
onAddToCanvas, onAddToCanvas,
onBack,
onPersistState, onPersistState,
persistedState, persistedState,
}: StyleProWorkspaceProps) { }: StyleProWorkspaceProps) {
@ -506,29 +506,29 @@ export function StyleProWorkspace({
: ""; : "";
return ( return (
<div className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-gray-50 text-gray-900"> <div className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]">
<style>{` <style>{`
.style-pro-index-shell .section-card { .style-pro-index-shell .section-card {
background: #fff; background: #fff;
border: 1px solid #e5e7eb; border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px; border-radius: 24px;
box-shadow: 0 12px 35px rgba(15, 23, 42, 0.06); box-shadow: 0 12px 35px rgba(15, 23, 42, 0.05);
} }
.style-pro-index-shell .subsection-title { .style-pro-index-shell .subsection-title {
color: #2563eb; color: #2563eb;
font-size: 14px; font-size: 13px;
font-weight: 700; font-weight: 650;
white-space: nowrap; white-space: nowrap;
} }
.style-pro-index-shell .option-btn { .style-pro-index-shell .option-btn {
border: 1px solid #e5e7eb; border: 1px solid rgba(0, 0, 0, 0.1);
background: #f9fafb; background: #fff;
color: #374151; color: rgba(17, 24, 39, 0.78);
transition: all 0.18s ease; transition: all 0.18s ease;
} }
.style-pro-index-shell .option-btn:hover { .style-pro-index-shell .option-btn:hover {
background: #1f2937; background: #1c1c1e;
border-color: #1f2937; border-color: #1c1c1e;
color: #fff; color: #fff;
} }
.style-pro-index-shell .option-btn.active { .style-pro-index-shell .option-btn.active {
@ -537,293 +537,100 @@ export function StyleProWorkspace({
border-color: #60a5fa; border-color: #60a5fa;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
} }
.style-pro-index-shell .preset-btn {
border-radius: 9999px;
transition: all 0.18s ease;
}
.style-pro-index-shell .custom-input {
border: 1px solid #e5e7eb;
background: #fafafa;
color: #111827;
outline: none;
}
.style-pro-index-shell .custom-input:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
}
`}</style> `}</style>
<aside className="flex w-[76px] shrink-0 flex-col items-center border-gray-200 border-r bg-white">
<button
className="mt-5 flex h-11 w-11 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-800"
onClick={onBack}
title="返回画布"
type="button"
>
{logo ? (
<img alt={siteName} className="h-6 w-6 object-contain" src={logo} />
) : (
<Sparkles className="h-5 w-5 text-blue-600" />
)}
</button>
<div className="mt-8 flex flex-col gap-3">
<button
className="flex h-11 w-11 items-center justify-center rounded-lg bg-blue-600 text-white shadow-sm"
onClick={onBack}
title="返回画布"
type="button"
>
<Home className="h-5 w-5" />
</button>
<button
className="flex h-11 w-11 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
onClick={() => fileInputRef.current?.click()}
title="上传图片"
type="button"
>
<ImagePlus className="h-5 w-5" />
</button>
</div>
</aside>
<main className="min-w-0 flex-1 overflow-y-auto"> <main className="min-w-0 flex-1 overflow-y-auto">
<div className="sticky top-0 z-40 border-gray-200 border-b bg-white"> <BrandTopBar
<div className="mx-auto flex max-w-[1920px] items-center justify-between gap-4 px-6 py-4"> logo={logo}
<div className="flex items-center gap-3"> onLogoClick={() => {
<Button window.location.href = "/";
className="rounded-lg border-gray-200 bg-white text-gray-700 hover:bg-gray-100" }}
onClick={onBack} position="sticky"
size="sm" siteName={siteName}
variant="outline" >
> <BrandTopBarButton onClick={quickFillRequired}>
<ArrowLeft className="mr-2 h-4 w-4" /> <UserRound className="h-3.5 w-3.5" />
返回画布 执行者
</Button> </BrandTopBarButton>
<div className="flex items-center gap-2"> <BrandTopBarButton
{logo ? ( disabled={generating || Boolean(disabledReason)}
<img alt={siteName} className="h-8 w-8 object-contain" src={logo} /> onClick={handleGenerate}
) : ( variant="primary"
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-600 text-white"> >
<Sparkles className="h-4 w-4" /> {generating ? (
</div> <Loader2 className="h-3.5 w-3.5 animate-spin" />
)} ) : (
<div> <Sparkles className="h-3.5 w-3.5" />
<div className="font-semibold text-gray-900 text-sm">{siteName}</div> )}
<div className="text-gray-500 text-xs">风格化 PRO</div> 执行
</div> </BrandTopBarButton>
</div> <BrandTopBarButton
</div> 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="flex flex-wrap items-center justify-end gap-3"> <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 <button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm" 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={quickFillRequired} onClick={() => fileInputRef.current?.click()}
type="button" onDragOver={(event) => event.preventDefault()}
> onDrop={(event) => {
<UserRound className="h-4 w-4" /> event.preventDefault();
执行者 void handleFile(event.dataTransfer.files?.[0]);
</button>
<button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm disabled:opacity-50"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
type="button"
>
{generating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
执行
</button>
<button
className="preset-btn flex items-center gap-2 border-2 border-blue-400 bg-gradient-to-r from-blue-500/20 to-blue-300/5 px-5 py-2 font-bold text-blue-700 text-sm"
onClick={() => {
setSelections({});
setCurrentResult(null);
}} }}
type="button" type="button"
> >
<RotateCcw className="h-4 w-4" /> {sourceImageUrl ? (
重置 <img
</button> alt="上传图片"
<button className="h-full max-h-[360px] w-full object-contain"
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-gray-700 text-sm hover:bg-gray-100" src={sourceImageUrl}
onClick={() => toast.info("个人中心入口已保留,可在首页打开完整账户面板")}
type="button"
>
<UserRound className="h-4 w-4" />
个人中心
</button>
</div>
</div>
</div>
<div className="mx-auto max-w-[1920px] space-y-6 px-6 py-8">
<section className="section-card p-6">
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="font-black text-4xl text-gray-900 lg:text-5xl">
风格化 PRO
</h1>
<p className="mt-2 text-gray-500 text-sm">
上传图片,选择下方按钮组合,按执行生成最终图片。
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge className="border-blue-200 bg-blue-50 text-blue-800">
{selectedOptionLabels.length
? `已选择 ${selectedOptionLabels.length} 项`
: "等待选择"}
</Badge>
{disabledReason ? (
<Badge className="border-amber-200 bg-amber-50 text-amber-700">
{disabledReason}
</Badge>
) : null}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-4">
<div className="rounded-xl bg-gray-50 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="subsection-title">上传图片</span>
<span className="text-gray-400 text-xs">支持拖拽上传</span>
</div>
<button
className="flex min-h-[260px] w-full items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-white transition hover:border-blue-400"
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-[320px] w-full object-contain"
src={sourceImageUrl}
/>
) : (
<span className="flex flex-col items-center gap-3 text-gray-500">
{uploading ? (
<Loader2 className="h-7 w-7 animate-spin" />
) : (
<Upload className="h-7 w-7" />
)}
<span className="text-sm">点击上传,或拖入图片</span>
</span>
)}
</button>
<input
accept="image/*"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.currentTarget.value = "";
}}
ref={fileInputRef}
type="file"
/> />
</div> ) : (
<span className="flex flex-col items-center gap-3 text-gray-500">
<div className="rounded-xl bg-gray-50 p-4"> {uploading ? (
<div className="mb-3 flex items-center justify-between gap-3"> <Loader2 className="h-6 w-6 animate-spin" />
<span className="subsection-title">历史记录</span>
<span className="text-gray-400 text-xs">最近 4 条</span>
</div>
<div className="grid grid-cols-2 gap-3">
{history.length > 0 ? (
history.map((item) => (
<button
className={cn(
"overflow-hidden rounded-lg border bg-white text-left transition hover:border-blue-400",
currentResult?.id === item.id
? "border-blue-400 shadow-[0_4px_12px_rgba(59,130,246,0.16)]"
: "border-gray-200"
)}
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-gray-700 text-xs">
{item.optionLabels[0] || "风格化结果"}
</div>
</div>
</button>
))
) : ( ) : (
<div className="col-span-2 rounded-lg border border-dashed border-gray-300 bg-white p-5 text-center text-gray-400 text-sm"> <Upload className="h-6 w-6" />
生成后会在这里保存历史。
</div>
)} )}
</div> <span className="text-[13px]">点击上传,或拖入图片</span>
</div> </span>
</div> )}
</button>
<div className="rounded-xl bg-gray-50 p-4"> <input
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> accept="image/*"
<div> className="hidden"
<span className="subsection-title">生图结果</span> onChange={(event) => {
<p className="mt-1 text-gray-500 text-sm"> void handleFile(event.target.files?.[0]);
生成后拖动滑块查看原图与结果图。 event.currentTarget.value = "";
</p> }}
</div> ref={fileInputRef}
<div className="flex flex-wrap gap-2"> type="file"
<Button />
className="rounded-lg bg-blue-600 text-white hover:bg-blue-700"
disabled={generating || Boolean(disabledReason)}
onClick={handleGenerate}
>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{generating ? "正在创建图片" : "执行"}
</Button>
<Button
className="rounded-lg border-gray-200 bg-white text-gray-700 hover:bg-gray-100"
disabled={!currentResult}
onClick={() => currentResult && onAddToCanvas?.(currentResult)}
variant="outline"
>
添加到画布
</Button>
</div>
</div>
<BeforeAfterSlider
afterUrl={currentResult?.resultImageUrl}
beforeUrl={currentResult?.sourceImageUrl || sourceImageUrl}
generating={generating}
/>
</div>
</div> </div>
</section> </section>
{configLoading ? ( {configLoading ? (
<section className="section-card flex min-h-[180px] items-center justify-center p-6 text-gray-500"> <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" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
正在加载配置 正在加载配置
</section> </section>
@ -836,38 +643,19 @@ export function StyleProWorkspace({
const selectedIds = selections[category.id] || []; const selectedIds = selections[category.id] || [];
return ( return (
<section className="section-card p-6" key={category.id}> <section className="section-card p-6" key={category.id}>
<div className="rounded-xl bg-gray-50 p-4"> <div className="rounded-[20px] bg-black/[0.025] p-4">
<div className="mb-3 flex items-start gap-4"> <div className="mb-3 flex items-start gap-4">
<span className="subsection-title mt-2 w-20"> <span className="subsection-title mt-2 w-20">
{category.title} {category.title}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-2">
{category.required ? (
<Badge className="border-blue-200 bg-blue-50 text-blue-800">
必选
</Badge>
) : (
<Badge className="border-gray-200 bg-white text-gray-500">
可选
</Badge>
)}
<span className="text-gray-400 text-xs">
已选 {selectedIds.length}/{category.maxSelections}
</span>
{category.description ? (
<span className="text-gray-400 text-xs">
{category.description}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{category.options.map((option) => { {category.options.map((option) => {
const selected = selectedIds.includes(option.id); const selected = selectedIds.includes(option.id);
return ( return (
<button <button
className={cn( className={cn(
"option-btn rounded-full px-4 py-2 font-medium text-sm", "option-btn rounded-full px-4 py-2 text-[14px] font-semibold",
selected && "active" selected && "active"
)} )}
key={option.id} key={option.id}
@ -882,12 +670,6 @@ export function StyleProWorkspace({
</div> </div>
</div> </div>
</div> </div>
<input
className="custom-input w-full rounded-lg px-4 py-2 text-sm"
disabled
placeholder={`自定义${category.title}描述...`}
type="text"
/>
</div> </div>
</section> </section>
); );
@ -895,56 +677,93 @@ export function StyleProWorkspace({
)} )}
<section className="section-card p-6"> <section className="section-card p-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
<div> <div className="rounded-[20px] bg-black/[0.025] p-4">
<h3 className="flex items-center gap-2 font-bold text-gray-900"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<Sparkles className="h-5 w-5 text-blue-600" /> <div>
已选组合 <span className="subsection-title">生图结果</span>
</h3> <p className="mt-1 text-[13px] text-black/45">
<p className="mt-1 text-gray-500 text-sm"> 生成后拖动滑块查看原图与结果图。
按钮提示词只在服务端参与生成,前台仅展示按钮名称。 </p>
</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>
<div className="flex flex-wrap gap-2">
<Button <div className="rounded-[20px] bg-black/[0.025] p-4">
className="rounded-lg border-gray-200 bg-white text-gray-700 hover:bg-gray-100" <div className="mb-3 flex items-center justify-between gap-3">
onClick={() => setSelections({})} <span className="subsection-title">历史记录</span>
variant="outline" <span className="text-[12px] text-black/40">最近 4 条</span>
> </div>
<RotateCcw className="mr-2 h-4 w-4" /> <div className="grid grid-cols-2 gap-3">
清空选择 {history.length > 0 ? (
</Button> history.map((item) => (
<Button <button
className="rounded-lg bg-blue-600 text-white hover:bg-blue-700" className={cn(
disabled={generating || Boolean(disabledReason)} "overflow-hidden rounded-2xl border bg-white text-left transition hover:border-black/35",
onClick={handleGenerate} currentResult?.id === item.id
> ? "border-black/30 shadow-[0_4px_12px_rgba(15,23,42,0.12)]"
{generating ? ( : "border-black/10"
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> )}
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>
))
) : ( ) : (
<Sparkles className="mr-2 h-4 w-4" /> <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>
</Button>
</div> </div>
</div> </div>
{selectedOptionLabels.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{selectedOptionLabels.map((label) => (
<Badge
className="border-blue-200 bg-blue-50 text-blue-800"
key={label}
variant="outline"
>
{label}
</Badge>
))}
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-5 text-center text-gray-400 text-sm">
还没有选择按钮。
</div>
)}
</section> </section>
</div> </div>
</main> </main>