feat: compact style pro prompt layout
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:33:08 +08:00
parent a188ee963a
commit 0ac796b9ff
3 changed files with 235 additions and 125 deletions

View File

@ -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

View File

@ -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">

View File

@ -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) => ({