refactor: polish realtime enhance workspace 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-04-06 01:50:06 +08:00
parent 97f3bf2c20
commit 94478e469f
2 changed files with 152 additions and 90 deletions

View File

@ -7,7 +7,13 @@ import {
Send,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
@ -93,14 +99,18 @@ const SOLID_PANEL_FIELD_CLASS =
"border-[#e7e1d4] bg-[#fffdfa] text-foreground shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
const SOLID_PANEL_CARD_CLASS =
"rounded-[22px] border border-[#e7e1d4] bg-[#fffdfa] shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
const PANEL_SECTION_TITLE_CLASS = "font-semibold text-[13px] text-slate-700";
const PANEL_SECTION_NOTE_CLASS = "text-[11px] leading-5 text-slate-500";
const PANEL_SECTION_TITLE_CLASS = "font-semibold text-[12px] text-slate-700";
const PANEL_SECTION_NOTE_CLASS = "text-[10.5px] leading-4 text-slate-500";
const SOLID_PANEL_SELECT_CONTENT_CLASS =
"border-[#e7e1d4] bg-[#fffdfa] shadow-[0_16px_34px_rgba(15,23,42,0.08)]";
const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#2563eb] bg-[#2563eb] text-white shadow-[0_10px_22px_rgba(37,99,235,0.18)] transition hover:border-[#1d4ed8] hover:bg-[#1d4ed8] disabled:border-[#e7e1d4] disabled:bg-slate-200 disabled:text-slate-500 disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS =
"border border-[#d8e4ff] bg-[#eef4ff] text-[#1d4ed8] shadow-[0_8px_20px_rgba(59,130,246,0.10)] transition hover:bg-[#e4eeff] hover:text-[#1e40af] disabled:border-[#e7e1d4] disabled:bg-slate-100 disabled:text-slate-400 disabled:shadow-none";
const COMPACT_FIELD_CLASS = `${SOLID_PANEL_FIELD_CLASS} h-10 rounded-[16px] text-sm`;
const COMPACT_LABEL_CLASS = "text-slate-600 text-[11px] leading-4";
const COMPACT_TOGGLE_CARD_CLASS =
"flex items-center justify-between gap-2.5 px-3 py-2.5";
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
@ -278,6 +288,15 @@ export function EnhanceProWorkspace({
EnhanceProImageResult[]
>([]);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [previewOffset, setPreviewOffset] = useState({ x: 0, y: 0 });
const [isPreviewDragging, setIsPreviewDragging] = useState(false);
const previewDragStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const { data: configPayload, isLoading } = useSWR<{
success?: boolean;
@ -352,6 +371,12 @@ export function EnhanceProWorkspace({
setLastErrorMessage("");
}, [sourceImageUrl]);
useEffect(() => {
setPreviewOffset({ x: 0, y: 0 });
setIsPreviewDragging(false);
previewDragStateRef.current = null;
}, [activePreviewUrl]);
const handleNumericField = <K extends keyof FormState>(
key: K,
nextValue: number
@ -474,7 +499,7 @@ export function EnhanceProWorkspace({
isSource?: boolean;
}> = [];
if (sourceImageUrl) {
if (generatedImages.length > 0 && sourceImageUrl) {
items.push({
key: "source",
label: "原图",
@ -494,21 +519,83 @@ export function EnhanceProWorkspace({
return items;
}, [generatedImages, sourceImageUrl]);
const handlePreviewPointerDown = (
event: ReactPointerEvent<HTMLDivElement>
) => {
if (!activePreviewUrl || event.button !== 0) {
return;
}
previewDragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: previewOffset.x,
originY: previewOffset.y,
};
setIsPreviewDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePreviewPointerMove = (
event: ReactPointerEvent<HTMLDivElement>
) => {
const dragState = previewDragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
setPreviewOffset({
x: dragState.originX + event.clientX - dragState.startX,
y: dragState.originY + event.clientY - dragState.startY,
});
};
const finishPreviewDrag = (event?: ReactPointerEvent<HTMLDivElement>) => {
const dragState = previewDragStateRef.current;
if (!dragState) {
return;
}
if (
event &&
event.currentTarget.hasPointerCapture(dragState.pointerId)
) {
event.currentTarget.releasePointerCapture(dragState.pointerId);
}
previewDragStateRef.current = null;
setIsPreviewDragging(false);
};
return (
<div className="relative flex h-full w-full bg-background text-foreground">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-muted/20">
{activePreviewUrl ? (
<>
<div
className={cn(
"flex h-full w-full items-center justify-center overflow-hidden touch-none select-none",
isPreviewDragging ? "cursor-grabbing" : "cursor-grab"
)}
onDoubleClick={() => setPreviewOffset({ x: 0, y: 0 })}
onPointerCancel={finishPreviewDrag}
onPointerDown={handlePreviewPointerDown}
onPointerMove={handlePreviewPointerMove}
onPointerUp={finishPreviewDrag}
>
<img
alt={activePreviewLabel}
className="max-h-full max-w-full object-contain"
className="pointer-events-none max-h-full max-w-full object-contain transition-transform duration-75"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
style={{
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
}}
/>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-1.5 text-muted-foreground text-xs shadow-sm backdrop-blur-sm">
<ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span>
</div>
</>
</div>
) : (
<div className="flex max-w-md flex-col items-center gap-3 px-6 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground/35" />
@ -522,7 +609,7 @@ export function EnhanceProWorkspace({
</div>
)}
{sidePreviewItems.length > 0 ? (
{generatedImages.length > 0 && sidePreviewItems.length > 0 ? (
<div className="absolute right-6 bottom-28 left-6 flex items-center justify-center">
<div className="flex max-w-[min(720px,calc(100vw-5rem))] items-center gap-2 rounded-[24px] border border-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-3 shadow-xl backdrop-blur-sm">
{sidePreviewItems.map((item, index) => {
@ -557,13 +644,13 @@ export function EnhanceProWorkspace({
) : null}
</div>
<div className="pointer-events-none absolute top-20 right-6 bottom-6 z-20 w-[420px] max-w-[42vw]">
<div className="h-full space-y-4 overflow-y-auto pr-1 pb-6">
<div className="pointer-events-none absolute top-5 right-5 bottom-5 z-20 w-[412px] max-w-[41vw]">
<div className="h-full space-y-3 overflow-y-auto pr-1 pb-3">
{isLoading ? (
<div
className={cn(
PANEL_SHELL_CLASS,
"flex items-center gap-2 px-4 py-3 text-muted-foreground text-sm"
"flex items-center gap-2 px-3.5 py-2.5 text-muted-foreground text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
@ -584,13 +671,11 @@ export function EnhanceProWorkspace({
</div>
) : null}
<div className={cn(PANEL_SHELL_CLASS, "space-y-4 px-4 py-4")}>
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-3.5 py-3.5")}>
<div className="space-y-2">
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue placeholder="选择一个增强预设" />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
@ -617,7 +702,7 @@ export function EnhanceProWorkspace({
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[92px] resize-none rounded-[22px] placeholder:text-muted-foreground/70`}
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[76px] resize-none rounded-[18px] text-sm placeholder:text-muted-foreground/70`}
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
@ -626,24 +711,21 @@ export function EnhanceProWorkspace({
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={3}
rows={2}
value={formState.prompt}
/>
</div>
</div>
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className={cn(PANEL_SHELL_CLASS, "space-y-2.5 px-3.5 py-3.5")}>
<div className={PANEL_SECTION_TITLE_CLASS}>基础参数</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-4 gap-2.5">
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-width"
>
<Label className={COMPACT_LABEL_CLASS} htmlFor="enhance-workspace-width">
宽度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-width"
max={config.maxOutputEdge}
min="1"
@ -662,14 +744,11 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-height"
>
<Label className={COMPACT_LABEL_CLASS} htmlFor="enhance-workspace-height">
高度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-height"
max={config.maxOutputEdge}
min="1"
@ -689,7 +768,7 @@ export function EnhanceProWorkspace({
</div>
{config.allowBatchSize ? (
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">候选</Label>
<Label className={COMPACT_LABEL_CLASS}>候选</Label>
<Select
onValueChange={(value) =>
handleNumericField(
@ -703,9 +782,7 @@ export function EnhanceProWorkspace({
}
value={String(formState.batchSize)}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
@ -722,14 +799,11 @@ export function EnhanceProWorkspace({
</div>
) : (
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-seed"
>
<Label className={COMPACT_LABEL_CLASS} htmlFor="enhance-workspace-seed">
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
@ -743,7 +817,7 @@ export function EnhanceProWorkspace({
</div>
)}
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">模式</Label>
<Label className={COMPACT_LABEL_CLASS}>模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
@ -753,9 +827,7 @@ export function EnhanceProWorkspace({
}
value={formState.model}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
@ -767,7 +839,7 @@ export function EnhanceProWorkspace({
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">格式</Label>
<Label className={COMPACT_LABEL_CLASS}>格式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
@ -777,9 +849,7 @@ export function EnhanceProWorkspace({
}
value={formState.output_format}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
@ -790,7 +860,7 @@ export function EnhanceProWorkspace({
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">区域</Label>
<Label className={COMPACT_LABEL_CLASS}>区域</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
@ -800,9 +870,7 @@ export function EnhanceProWorkspace({
}
value={formState.subject_detection}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
@ -814,14 +882,11 @@ export function EnhanceProWorkspace({
</div>
{config.allowBatchSize ? (
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-seed"
>
<Label className={COMPACT_LABEL_CLASS} htmlFor="enhance-workspace-seed">
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
@ -835,14 +900,11 @@ export function EnhanceProWorkspace({
</div>
) : null}
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-scale"
>
<Label className={COMPACT_LABEL_CLASS} htmlFor="enhance-workspace-scale">
放大
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-scale"
max="32"
min="1"
@ -864,16 +926,16 @@ export function EnhanceProWorkspace({
</div>
</div>
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className={cn(PANEL_SHELL_CLASS, "space-y-2.5 px-3.5 py-3.5")}>
<div className={PANEL_SECTION_TITLE_CLASS}>增强开关</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-3 gap-2.5">
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
COMPACT_TOGGLE_CARD_CLASS
)}
>
<span className="text-foreground text-sm">人脸增强</span>
<span className="text-foreground text-xs">人脸增强</span>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
@ -887,10 +949,10 @@ export function EnhanceProWorkspace({
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
COMPACT_TOGGLE_CARD_CLASS
)}
>
<span className="text-foreground text-sm">启用放大</span>
<span className="text-foreground text-xs">启用放大</span>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
@ -904,10 +966,10 @@ export function EnhanceProWorkspace({
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
COMPACT_TOGGLE_CARD_CLASS
)}
>
<span className="text-foreground text-sm">裁剪铺满</span>
<span className="text-foreground text-xs">裁剪铺满</span>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
@ -919,16 +981,16 @@ export function EnhanceProWorkspace({
</div>
{config.enableAdvancedControls ? (
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className={cn(PANEL_SHELL_CLASS, "space-y-2.5 px-3.5 py-3.5")}>
<div className="flex items-center justify-between gap-3">
<div className={PANEL_SECTION_TITLE_CLASS}>高级调节</div>
<div className={PANEL_SECTION_NOTE_CLASS}>细修参数</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-4 gap-2.5">
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">创造力</Label>
<Label className={COMPACT_LABEL_CLASS}>创造力</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="6"
min="1"
onChange={(event) =>
@ -946,9 +1008,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">纹理</Label>
<Label className={COMPACT_LABEL_CLASS}>纹理</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="5"
min="1"
onChange={(event) =>
@ -966,9 +1028,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">细节</Label>
<Label className={COMPACT_LABEL_CLASS}>细节</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="1"
min="0"
onChange={(event) =>
@ -987,9 +1049,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">锐化</Label>
<Label className={COMPACT_LABEL_CLASS}>锐化</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="1"
min="0"
onChange={(event) =>
@ -1008,9 +1070,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">降噪</Label>
<Label className={COMPACT_LABEL_CLASS}>降噪</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="1"
min="0"
onChange={(event) =>
@ -1029,9 +1091,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">脸部力度</Label>
<Label className={COMPACT_LABEL_CLASS}>脸部力度</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="1"
min="0"
onChange={(event) =>
@ -1050,9 +1112,9 @@ export function EnhanceProWorkspace({
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">脸部创意</Label>
<Label className={COMPACT_LABEL_CLASS}>脸部创意</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
className={COMPACT_FIELD_CLASS}
max="1"
min="0"
onChange={(event) =>
@ -1076,8 +1138,8 @@ export function EnhanceProWorkspace({
</div>
</div>
<div className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center gap-2">
<div className="pointer-events-auto rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-4 py-1.5 text-muted-foreground text-xs shadow-lg backdrop-blur-xl">
<div className="pointer-events-none absolute bottom-5 left-1/2 z-30 flex w-fit max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center gap-1.5">
<div className="pointer-events-auto rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-3.5 py-1 text-muted-foreground text-[11px] shadow-lg backdrop-blur-xl">
<span className="inline-flex items-center gap-1.5">
{selectedGeneratedImage ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
@ -1085,10 +1147,10 @@ export function EnhanceProWorkspace({
{actionHint}
</span>
</div>
<div className="pointer-events-auto inline-flex items-center justify-center gap-3 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 p-2 shadow-2xl backdrop-blur-xl">
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 p-1.5 shadow-2xl backdrop-blur-xl">
<Button
className={cn(
"h-11 min-w-[220px] rounded-full px-6",
"h-10 min-w-[208px] rounded-full px-5 text-sm",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={isSubmitting || !sourceImageUrl}
@ -1104,7 +1166,7 @@ export function EnhanceProWorkspace({
</Button>
<Button
className={cn(
"h-11 min-w-[220px] rounded-full px-6",
"h-10 min-w-[208px] rounded-full px-5 text-sm",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedGeneratedImage}

View File

@ -2970,7 +2970,7 @@ export function RealtimeCanvasDialog({
<div className="pointer-events-none absolute top-6 left-6 right-6 z-50 flex flex-wrap items-start gap-3">
<button
className={cn(
"pointer-events-auto flex h-16 items-center gap-3 rounded-full px-5 text-foreground transition hover:bg-white",
"pointer-events-auto flex h-16 items-center gap-2.5 rounded-full px-4 text-foreground transition hover:bg-white",
floatingSurfaceClass
)}
onClick={handleGoHome}