feat: compact style pro prompt layout
This commit is contained in:
parent
a188ee963a
commit
0ac796b9ff
@ -386,6 +386,30 @@ export function StyleProSettingsClient() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>提示词模块展示方式</Label>
|
||||
<Select
|
||||
onValueChange={(promptDisplayMode) =>
|
||||
updateConfig({
|
||||
promptDisplayMode:
|
||||
promptDisplayMode === "carousel" ? "carousel" : "stacked",
|
||||
})
|
||||
}
|
||||
value={config.promptDisplayMode || "stacked"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stacked">纵向模块列表</SelectItem>
|
||||
<SelectItem value="carousel">模块左右滑动模式</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
左右滑动模式会把多个提示词分类收纳到同一行横向滑动区域,减少前台页面高度占用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>默认系统提示词</Label>
|
||||
<Textarea
|
||||
|
||||
@ -3,19 +3,21 @@
|
||||
import {
|
||||
Clock3,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Sparkles,
|
||||
Upload,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
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 { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
@ -68,10 +70,6 @@ function getHistoryId() {
|
||||
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) {
|
||||
@ -143,12 +141,16 @@ function GeneratingImagePlaceholder() {
|
||||
生成中
|
||||
</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 className="style-pro-generating-frame relative mx-auto h-[390px] max-h-[58vh] w-full max-w-[340px] overflow-hidden rounded-[28px] p-[2px]">
|
||||
<div className="style-pro-generating-surface relative h-full overflow-hidden rounded-[26px] bg-[#f4f4f4]">
|
||||
<div className="style-pro-dot-field absolute inset-0" />
|
||||
<div className="style-pro-light-field absolute inset-0" />
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes styleProBorderSpin {
|
||||
to { transform: rotate(1turn); }
|
||||
}
|
||||
@keyframes styleProDotDrift {
|
||||
0% { transform: translate3d(-10px, -8px, 0); opacity: 0.35; }
|
||||
45% { transform: translate3d(8px, 18px, 0); opacity: 0.78; }
|
||||
@ -159,14 +161,39 @@ function GeneratingImagePlaceholder() {
|
||||
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);
|
||||
background: rgba(255,255,255,0.82);
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.08);
|
||||
isolation: isolate;
|
||||
}
|
||||
.style-pro-generating-frame::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: -42%;
|
||||
z-index: -1;
|
||||
background:
|
||||
conic-gradient(
|
||||
from 0deg,
|
||||
rgba(22, 163, 74, 0.18),
|
||||
rgba(37, 99, 235, 0.46),
|
||||
rgba(245, 158, 11, 0.32),
|
||||
rgba(20, 184, 166, 0.42),
|
||||
rgba(22, 163, 74, 0.18)
|
||||
);
|
||||
animation: styleProBorderSpin 5.2s linear infinite;
|
||||
}
|
||||
.style-pro-generating-frame::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 2px;
|
||||
border-radius: 26px;
|
||||
pointer-events: none;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255,255,255,0.72),
|
||||
inset 0 0 28px rgba(255,255,255,0.55);
|
||||
}
|
||||
.style-pro-generating-surface {
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.025);
|
||||
}
|
||||
.style-pro-dot-field {
|
||||
background-image: radial-gradient(circle, rgba(116,116,116,0.32) 1.05px, transparent 1.05px);
|
||||
@ -176,15 +203,14 @@ function GeneratingImagePlaceholder() {
|
||||
}
|
||||
.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%);
|
||||
radial-gradient(ellipse 52% 38% at 28% 28%, rgba(255,255,255,0.88) 0%, rgba(255,255,255,0.52) 38%, transparent 72%),
|
||||
radial-gradient(ellipse 56% 42% at 72% 70%, rgba(255,255,255,0.78) 0%, rgba(255,255,255,0.42) 36%, transparent 74%),
|
||||
radial-gradient(ellipse 90% 70% at 50% 50%, rgba(255,255,255,0.28) 0%, transparent 68%);
|
||||
filter: blur(14px);
|
||||
inset: -14%;
|
||||
mix-blend-mode: screen;
|
||||
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;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
@ -201,6 +227,54 @@ function BeforeAfterSlider({
|
||||
generating?: boolean;
|
||||
}) {
|
||||
const [position, setPosition] = useState(50);
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const updatePositionFromClientX = useCallback((clientX: number) => {
|
||||
const rect = sliderRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0) return;
|
||||
const next = ((clientX - rect.left) / rect.width) * 100;
|
||||
setPosition(Math.min(96, Math.max(4, Math.round(next * 10) / 10)));
|
||||
}, []);
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
updatePositionFromClientX(event.clientX);
|
||||
},
|
||||
[updatePositionFromClientX]
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
updatePositionFromClientX(event.clientX);
|
||||
},
|
||||
[updatePositionFromClientX]
|
||||
);
|
||||
const handlePointerEnd = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const handleSliderKeyDown = useCallback(
|
||||
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const step = event.shiftKey ? 10 : 3;
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setPosition((current) => Math.max(4, current - step));
|
||||
} else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setPosition((current) => Math.min(96, current + step));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setPosition(4);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
setPosition(96);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (generating) {
|
||||
return <GeneratingImagePlaceholder />;
|
||||
@ -223,7 +297,10 @@ function BeforeAfterSlider({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[460px] overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||
<div
|
||||
className="relative min-h-[460px] select-none overflow-hidden rounded-lg border border-gray-200 bg-gray-50"
|
||||
ref={sliderRef}
|
||||
>
|
||||
<ImagePreview
|
||||
alt="生成后"
|
||||
className="absolute inset-0 h-full w-full"
|
||||
@ -240,22 +317,26 @@ function BeforeAfterSlider({
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-white"
|
||||
className="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">
|
||||
<div
|
||||
aria-label="拖动查看原图和结果对比"
|
||||
aria-valuemax={96}
|
||||
aria-valuemin={4}
|
||||
aria-valuenow={Math.round(position)}
|
||||
className="-translate-x-1/2 absolute top-1/2 flex h-11 w-11 -translate-y-1/2 cursor-ew-resize touch-none select-none items-center justify-center rounded-full border border-white/80 bg-black/75 text-[11px] font-semibold text-white shadow-lg outline-none transition hover:bg-black focus-visible:ring-2 focus-visible:ring-black/40 focus-visible:ring-offset-2"
|
||||
onKeyDown={handleSliderKeyDown}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
>
|
||||
对比
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
@ -413,18 +494,6 @@ export function StyleProWorkspace({
|
||||
[]
|
||||
);
|
||||
|
||||
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("请先上传一张图片");
|
||||
@ -504,6 +573,48 @@ export function StyleProWorkspace({
|
||||
: missingRequiredCategories.length > 0
|
||||
? `还差:${missingRequiredCategories[0].title}`
|
||||
: "";
|
||||
const promptDisplayMode = config?.promptDisplayMode || "stacked";
|
||||
const renderPromptCategory = (
|
||||
category: PublicStyleProCategory,
|
||||
layout: "stacked" | "carousel" = "stacked"
|
||||
) => {
|
||||
const selectedIds = selections[category.id] || [];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-[20px] bg-black/[0.025] p-4",
|
||||
layout === "carousel" && "h-full min-h-[126px]"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-4">
|
||||
<span className="subsection-title mt-2 w-20 shrink-0">
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="style-pro-index-shell flex h-dvh w-full overflow-hidden bg-[#f4f4f4] text-[#171717]">
|
||||
@ -537,6 +648,21 @@ export function StyleProWorkspace({
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
.style-pro-index-shell .prompt-carousel-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(15, 23, 42, 0.18) transparent;
|
||||
scroll-padding-inline: 8px;
|
||||
}
|
||||
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.style-pro-index-shell .prompt-carousel-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(15, 23, 42, 0.16);
|
||||
border-radius: 999px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||
@ -547,40 +673,7 @@ export function StyleProWorkspace({
|
||||
}}
|
||||
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">
|
||||
@ -638,42 +731,25 @@ export function StyleProWorkspace({
|
||||
<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>
|
||||
) : promptDisplayMode === "carousel" ? (
|
||||
<section className="section-card p-6">
|
||||
<div className="prompt-carousel-scroll -mx-2 flex snap-x gap-4 overflow-x-auto px-2 pb-2">
|
||||
{enabledCategories.map((category) => (
|
||||
<div
|
||||
className="w-[min(760px,calc(100vw-96px))] shrink-0 snap-start"
|
||||
key={category.id}
|
||||
>
|
||||
{renderPromptCategory(category, "carousel")}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
enabledCategories.map((category) => (
|
||||
<section className="section-card p-6" key={category.id}>
|
||||
{renderPromptCategory(category)}
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
|
||||
<section className="section-card p-6">
|
||||
|
||||
@ -22,6 +22,8 @@ export type StyleProCategory = {
|
||||
options: StyleProOption[];
|
||||
};
|
||||
|
||||
export type StyleProPromptDisplayMode = "stacked" | "carousel";
|
||||
|
||||
export type StyleProConfig = {
|
||||
enabled: boolean;
|
||||
modelId: string;
|
||||
@ -29,6 +31,7 @@ export type StyleProConfig = {
|
||||
negativePrompt: string;
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
promptDisplayMode: StyleProPromptDisplayMode;
|
||||
categories: StyleProCategory[];
|
||||
};
|
||||
|
||||
@ -405,6 +408,7 @@ export const defaultStyleProConfig: StyleProConfig = {
|
||||
negativePrompt: "低清晰度,畸变,文字乱码,主体变形,产品结构错误,多余物体,水印",
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
promptDisplayMode: "stacked",
|
||||
categories: INDEX_25_STYLE_PRO_CATEGORIES,
|
||||
};
|
||||
|
||||
@ -433,6 +437,10 @@ function cleanNumber(value: unknown, fallback: number, min: number, max: number)
|
||||
return Math.min(max, Math.max(min, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function cleanPromptDisplayMode(value: unknown): StyleProPromptDisplayMode {
|
||||
return value === "carousel" ? "carousel" : "stacked";
|
||||
}
|
||||
|
||||
function sortByOrder<T extends { order: number; id: string }>(items: T[]) {
|
||||
return [...items].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
||||
}
|
||||
@ -507,6 +515,7 @@ export function normalizeStyleProConfig(value: unknown): StyleProConfig {
|
||||
negativePrompt: cleanString(raw.negativePrompt, fallback.negativePrompt, 4000),
|
||||
aspectRatio: cleanString(raw.aspectRatio, fallback.aspectRatio, 32),
|
||||
imageSize: cleanString(raw.imageSize, fallback.imageSize, 32),
|
||||
promptDisplayMode: cleanPromptDisplayMode(raw.promptDisplayMode),
|
||||
categories: isLegacyDefaultCategories(sortedCategories)
|
||||
? fallback.categories
|
||||
: sortedCategories,
|
||||
@ -572,6 +581,7 @@ export function toPublicStyleProConfig(
|
||||
enabled: config.enabled,
|
||||
aspectRatio: config.aspectRatio,
|
||||
imageSize: config.imageSize,
|
||||
promptDisplayMode: config.promptDisplayMode,
|
||||
categories: config.categories
|
||||
.filter((category) => category.enabled)
|
||||
.map((category) => ({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user