ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name b6e1a6c657
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add enhance pro points pricing rules
2026-04-10 14:05:23 +08:00

1397 lines
51 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 {
CheckCircle2,
ChevronDown,
ImageIcon,
Loader2,
ScanFace,
Send,
Settings2,
SlidersHorizontal,
Sparkles,
} from "lucide-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";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
defaultEnhanceProPresets,
type EnhanceProKreaModel,
type EnhanceProOutputFormat,
type EnhanceProPreset,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
calculateEnhanceProPointsEstimate,
defaultPointsSystemConfig,
type EnhanceProPointsPricingConfig,
} from "@/lib/points/config";
import { cn } from "@/lib/utils";
import {
type EnhanceProImageResult,
type EnhanceProRequestParams,
imageGenerationService,
} from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: number;
maxOutputEdge: number;
enableAdvancedControls: boolean;
presets: EnhanceProPreset[];
};
type EnhanceProClientPointsConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
type FormState = {
prompt: string;
width: number;
height: number;
batchSize: number;
seed: number;
output_format: EnhanceProOutputFormat;
subject_detection: EnhanceProSubjectDetection;
face_enhancement: boolean;
face_enhancement_creativity: number;
face_enhancement_strength: number;
crop_to_fill: boolean;
model: EnhanceProKreaModel;
upscaling_activated: boolean;
image_scaling_factor: number;
creativity: number;
texture: number;
sharpen: number;
denoise: number;
detail: number;
};
const FALLBACK_CONFIG: EnhanceProClientConfig = {
enabled: false,
providerName: "Krea Topaz Generative",
baseUrl: "https://api.krea.ai",
defaultModel: "Redefine",
defaultOutputFormat: "jpg",
allowBatchSize: true,
maxBatchSize: 4,
maxOutputEdge: 4096,
enableAdvancedControls: true,
presets: defaultEnhanceProPresets(),
};
const FALLBACK_POINTS_CONFIG: EnhanceProClientPointsConfig = {
enabled: false,
blockOnInsufficient: true,
enhanceProPricing: defaultPointsSystemConfig.enhanceProPricing,
};
const PANEL_SHELL_CLASS =
"pointer-events-auto rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_20px_40px_rgba(0,0,0,0.06)]";
const SOLID_PANEL_FIELD_CLASS =
"border-[#e5e5e5] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.04)]";
const SOLID_PANEL_CARD_CLASS =
"rounded-[22px] border border-[#ececec] bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_22px_rgba(0,0,0,0.04)]";
const PANEL_SECTION_TITLE_CLASS =
"font-semibold text-[15px] tracking-[-0.04em] text-[#111111]";
const PANEL_SECTION_NOTE_CLASS =
"text-[12px] leading-5 text-[#666666]";
const SOLID_PANEL_SELECT_CONTENT_CLASS =
"border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.08)]";
const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS =
"border border-[#d9d9d9] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_22px_rgba(0,0,0,0.03)] transition hover:border-[#111111] hover:bg-[#fafafa] hover:text-black disabled:border-[#e5e5e5] disabled:bg-[#f7f7f7] disabled:text-[#a5a5a5] disabled:shadow-none";
const COMPACT_FIELD_CLASS = `${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[16px] text-sm`;
const COMPACT_LABEL_CLASS =
"font-medium text-[#666666] text-[11px] leading-4 tracking-[0.01em]";
const COMPACT_TOGGLE_CARD_CLASS =
"flex items-center justify-between gap-2.5 px-3.5 py-3";
const SLIDER_TRACK_CLASS = "h-2.5 bg-[#ededed] shadow-inner";
const SLIDER_RANGE_CLASS = "bg-[#111111]";
const SLIDER_THUMB_CLASS =
"h-4 w-4 border border-[#d4d4d4] bg-white shadow-[0_0_0_4px_rgba(0,0,0,0.08)]";
const SIDEBAR_SCROLL_CLASS =
"pr-1.5 [scrollbar-width:thin] [scrollbar-color:#c8c8c8_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#c8c8c8] hover:[&::-webkit-scrollbar-thumb]:bg-[#a8a8a8]";
const SIDEBAR_SECTION_CARD_CLASS =
"rounded-[28px] border border-[#e5e5e5] bg-white p-3.5 shadow-[0_18px_36px_rgba(0,0,0,0.05)]";
const SECTION_EYEBROW_CLASS =
"inline-flex items-center gap-1.5 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1 font-semibold text-[10px] tracking-[0.14em] text-[#666666] uppercase";
const INNER_PANEL_CLASS =
"rounded-[22px] border border-[#efefef] bg-[#fbfbfb] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]";
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
const maxEdge = Math.max(
512,
Math.round(maxOutputEdge || FALLBACK_CONFIG.maxOutputEdge)
);
const currentMax = Math.max(safeWidth, safeHeight);
if (currentMax <= maxEdge) {
return { width: safeWidth, height: safeHeight };
}
const scale = maxEdge / currentMax;
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
function createDefaultFormState(
config: EnhanceProClientConfig,
width: number,
height: number
): FormState {
const size = fitDimensions(width, height, config.maxOutputEdge);
return {
prompt: "",
width: size.width,
height: size.height,
batchSize: 1,
seed: 1337,
output_format: config.defaultOutputFormat,
subject_detection: "All",
face_enhancement: false,
face_enhancement_creativity: 0.5,
face_enhancement_strength: 0.5,
crop_to_fill: false,
model: config.defaultModel,
upscaling_activated: false,
image_scaling_factor: 1,
creativity: 3,
texture: 3,
sharpen: 0.5,
denoise: 0.5,
detail: 0.5,
};
}
function applyPresetToForm(
config: EnhanceProClientConfig,
width: number,
height: number,
preset?: EnhanceProPreset
): FormState {
const baseState = createDefaultFormState(config, width, height);
if (!preset) {
return baseState;
}
return {
...baseState,
prompt: preset.prompt,
batchSize: config.allowBatchSize
? clampNumber(Math.round(preset.batchSize || 1), 1, config.maxBatchSize)
: 1,
seed: Number.isFinite(Number(preset.seed))
? Math.round(Number(preset.seed))
: baseState.seed,
output_format: preset.outputFormat,
subject_detection: preset.subjectDetection,
face_enhancement: preset.faceEnhancement,
face_enhancement_creativity: clampNumber(
preset.faceEnhancementCreativity,
0,
1
),
face_enhancement_strength: clampNumber(
preset.faceEnhancementStrength,
0,
1
),
crop_to_fill: preset.cropToFill,
model: preset.model,
upscaling_activated: preset.upscalingActivated,
image_scaling_factor: clampNumber(preset.imageScalingFactor, 1, 32),
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
texture: clampNumber(Math.round(preset.texture), 1, 5),
sharpen: clampNumber(preset.sharpen, 0, 1),
denoise: clampNumber(preset.denoise, 0, 1),
detail: clampNumber(preset.detail, 0, 1),
};
}
function getDisplayImageUrl(url?: string | null, width = 1400) {
const raw = String(url || "").trim();
if (!raw) return "";
return /^https?:\/\//i.test(raw)
? buildImageProxyUrl(raw, {
width,
quality: 88,
format: "webp",
})
: raw;
}
function getSubjectDetectionLabel(
subjectDetection: EnhanceProSubjectDetection
) {
if (subjectDetection === "Foreground") return "前景";
if (subjectDetection === "Background") return "背景";
return "全部";
}
function getActiveResultLabel(index: number) {
return `结果 ${index + 1}`;
}
type ParameterSliderRowProps = {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
formatValue?: (value: number) => string;
disabled?: boolean;
};
function ParameterSliderRow({
label,
value,
min,
max,
step,
onChange,
formatValue,
disabled = false,
}: ParameterSliderRowProps) {
const displayValue = formatValue ? formatValue(value) : String(value);
return (
<div
className={cn(
"space-y-3 rounded-[20px] border border-[#efefef] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]",
disabled && "opacity-50"
)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[12px] text-[#222222] tracking-[-0.01em]">
{label}
</div>
<div className="inline-flex min-w-[54px] justify-center rounded-full border border-[#e7e7e7] bg-white px-2.5 py-1 font-medium text-[11px] text-[#666666] shadow-[0_4px_12px_rgba(0,0,0,0.04)]">
{displayValue}
</div>
</div>
<Slider
className="w-full"
disabled={disabled}
max={max}
min={min}
onValueChange={([next]) => onChange(next ?? value)}
rangeClassName={SLIDER_RANGE_CLASS}
step={step}
thumbClassName={SLIDER_THUMB_CLASS}
trackClassName={SLIDER_TRACK_CLASS}
value={[value]}
/>
</div>
);
}
function buildEnhanceSourcePayload(
sourceImageUrl?: string | null
): Pick<
EnhanceProRequestParams,
"imageUrl" | "image_base64" | "mimeType"
> | null {
const raw = String(sourceImageUrl || "").trim();
if (!raw) return null;
const dataUrlMatch = raw.match(/^data:([^;]+);base64,(.+)$/);
if (dataUrlMatch) {
return {
image_base64: dataUrlMatch[2],
mimeType: dataUrlMatch[1] || "image/png",
};
}
if (/^https?:\/\//i.test(raw)) {
return {
imageUrl: raw,
};
}
return null;
}
interface EnhanceProWorkspaceProps {
sourceImageUrl?: string | null;
onApplyResult: (payload: {
imageUrl: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => void;
}
export function EnhanceProWorkspace({
sourceImageUrl,
onApplyResult,
}: EnhanceProWorkspaceProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
const [selectedPresetId, setSelectedPresetId] = useState("");
const [selectedResultIndex, setSelectedResultIndex] = useState<number>(-1);
const [formState, setFormState] = useState<FormState>(() =>
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024)
);
const [generatedImages, setGeneratedImages] = useState<
EnhanceProImageResult[]
>([]);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [settingsOpen, setSettingsOpen] = useState(true);
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
const [faceOpen, setFaceOpen] = useState(true);
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;
config?: EnhanceProClientConfig;
pointsConfig?: EnhanceProClientPointsConfig;
}>("/api/enhance/pro/config", fetcher);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config]
);
const pointsConfig = useMemo(
() => configPayload?.pointsConfig || FALLBACK_POINTS_CONFIG,
[configPayload?.pointsConfig]
);
const presets = useMemo(
() => (config.presets.length ? config.presets : FALLBACK_CONFIG.presets),
[config.presets]
);
const selectedPreset = useMemo(
() => presets.find((item) => item.id === selectedPresetId) || null,
[presets, selectedPresetId]
);
const selectedGeneratedImage =
selectedResultIndex >= 0
? generatedImages[selectedResultIndex] || null
: null;
const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex)
: "原图";
const actionHint = selectedGeneratedImage
? `已选中 ${activePreviewLabel}`
: generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布";
const pointsEstimate = useMemo(
() =>
calculateEnhanceProPointsEstimate(
{
...defaultPointsSystemConfig,
enabled: pointsConfig.enabled,
blockOnInsufficient: pointsConfig.blockOnInsufficient,
enhanceProPricing: pointsConfig.enhanceProPricing,
},
{
width: formState.width,
height: formState.height,
batchSize: formState.batchSize,
model: formState.model,
}
),
[formState.batchSize, formState.height, formState.model, formState.width, pointsConfig]
);
useEffect(() => {
const imageSource = String(sourceImageUrl || "").trim();
if (!imageSource) {
setNaturalSize({ width: 1024, height: 1024 });
return;
}
const image = new window.Image();
image.onload = () => {
setNaturalSize({
width: image.naturalWidth || 1024,
height: image.naturalHeight || 1024,
});
};
image.onerror = () => {
setNaturalSize({ width: 1024, height: 1024 });
};
image.src = imageSource;
}, [sourceImageUrl]);
useEffect(() => {
const defaultPreset =
presets.find((preset) => preset.isDefault) || presets[0];
setSelectedPresetId(defaultPreset?.id || "");
setFormState(
applyPresetToForm(
config,
naturalSize.width,
naturalSize.height,
defaultPreset
)
);
}, [config, naturalSize.height, naturalSize.width, presets]);
useEffect(() => {
setGeneratedImages([]);
setSelectedResultIndex(-1);
setLastErrorMessage("");
}, [sourceImageUrl]);
useEffect(() => {
setPreviewOffset({ x: 0, y: 0 });
setIsPreviewDragging(false);
previewDragStateRef.current = null;
}, [activePreviewUrl]);
useEffect(() => {
if (formState.face_enhancement) {
setFaceOpen(true);
}
}, [formState.face_enhancement]);
const handleNumericField = <K extends keyof FormState>(
key: K,
nextValue: number
) => {
setFormState((prev) => ({
...prev,
[key]: nextValue,
}));
};
const handleSelectPreset = (presetId: string) => {
const preset = presets.find((item) => item.id === presetId);
setSelectedPresetId(presetId);
setFormState(
applyPresetToForm(config, naturalSize.width, naturalSize.height, preset)
);
};
const handleSubmit = async () => {
if (!config.enabled) {
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
return;
}
const sourcePayload = buildEnhanceSourcePayload(sourceImageUrl);
if (!sourcePayload) {
toast.error(
"当前没有可用于增强的图片,请先选择画布图片或切回实时模式准备一张结果图"
);
return;
}
setIsSubmitting(true);
setLastErrorMessage("");
try {
const result = await imageGenerationService.enhanceImagePro({
...sourcePayload,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(
Math.round(formState.width),
1,
config.maxOutputEdge
),
height: clampNumber(
Math.round(formState.height),
1,
config.maxOutputEdge
),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(
formState.face_enhancement_creativity,
0,
1
),
face_enhancement_strength: clampNumber(
formState.face_enhancement_strength,
0,
1
),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
32
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
});
if (!result.success || !result.data?.images?.length) {
throw new Error(result.error || "图像增强 Pro 失败");
}
setGeneratedImages(result.data.images);
setSelectedResultIndex(0);
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
);
} catch (error) {
const message =
error instanceof Error ? error.message : "图像增强 Pro 失败";
setLastErrorMessage(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleApply = () => {
if (!selectedGeneratedImage?.originalUrl) {
toast.error("请先生成结果并选择一张增强图");
return;
}
onApplyResult({
imageUrl: selectedGeneratedImage.originalUrl,
prompt: formState.prompt.trim() || "图像增强 Pro",
title: "图像增强 Pro 结果",
model: selectedPreset?.model || config.defaultModel,
width: selectedGeneratedImage.width,
height: selectedGeneratedImage.height,
});
};
const sidePreviewItems = useMemo(() => {
const items: Array<{
key: string;
label: string;
url: string;
isSource?: boolean;
}> = [];
if (generatedImages.length > 0 && sourceImageUrl) {
items.push({
key: "source",
label: "原图",
url: sourceImageUrl,
isSource: true,
});
}
generatedImages.forEach((image, index) => {
items.push({
key: `result-${index}`,
label: getActiveResultLabel(index),
url: image.thumbnailUrl || image.originalUrl,
});
});
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-[#ffffff] text-[#111111]">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden px-6 pb-28 pt-24">
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025] [mask-image:radial-gradient(circle_at_center,black_38%,transparent_82%)]" />
{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}
>
<div className="relative flex max-h-full max-w-full items-center justify-center rounded-[38px] border border-[#e8e8e8] bg-[#fafafa] p-4 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_24px_56px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<div className="overflow-hidden rounded-[30px] border border-[#efefef] bg-white shadow-[0_18px_30px_rgba(0,0,0,0.08)]">
<img
alt={activePreviewLabel}
className="pointer-events-none max-h-[calc(100vh-17rem)] max-w-[min(1000px,calc(100vw-35rem))] object-contain transition-transform duration-75"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
style={{
transform: `translate(${previewOffset.x}px, ${previewOffset.y}px)`,
}}
/>
</div>
</div>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3.5 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm">
<ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span>
</div>
<div className="pointer-events-none absolute top-6 right-8 hidden items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] lg:inline-flex">
双击回正
</div>
</div>
) : (
<div className="relative w-full max-w-[42rem] px-6">
<div className="relative rounded-[38px] border border-[#e6e6e6] bg-white px-8 py-8 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_30px_60px_rgba(0,0,0,0.06)] backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-[24px] border border-[#ececec] bg-[#fafafa] text-[#111111] shadow-[0_14px_28px_rgba(0,0,0,0.06)]">
<Sparkles className="h-8 w-8" />
</div>
<div className="min-w-0 text-left">
<div className={SECTION_EYEBROW_CLASS}>Enhance Pro Studio</div>
<div className="mt-3 font-semibold text-[34px] tracking-[-0.05em] text-[#111111] leading-[1.02]">
还没有可增强的图片
</div>
<div className="mt-3 max-w-[30rem] text-[#666666] text-[15px] leading-7">
先在主画布里选中一张图片,或者先在实时画布模式里生成结果,
再切回这里进行精修、增强和细节重建。
</div>
</div>
</div>
<div className="mt-7 grid gap-3 md:grid-cols-2">
<div className="rounded-[24px] border border-[#efefef] bg-[#fafafa] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]">
<div className="text-[11px] font-semibold tracking-[0.16em] text-[#666666] uppercase">
Step 01
</div>
<div className="mt-2 font-semibold text-[18px] tracking-[-0.03em] text-[#111111]">
选中一张图
</div>
<div className="mt-1 text-[13px] leading-6 text-[#666666]">
从主画布中选择要增强的图片,系统会自动把它带入当前工作台。
</div>
</div>
<div className="rounded-[24px] border border-[#efefef] bg-[#fafafa] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]">
<div className="text-[11px] font-semibold tracking-[0.16em] text-[#666666] uppercase">
Step 02
</div>
<div className="mt-2 font-semibold text-[18px] tracking-[-0.03em] text-[#111111]">
调参后开始增强
</div>
<div className="mt-1 text-[13px] leading-6 text-[#666666]">
使用右侧工具卡片快速选择模式、强度和增强范围,生成后可直接发回画布。
</div>
</div>
</div>
</div>
</div>
)}
{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-[#e5e5e5] bg-white/96 px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.05)] backdrop-blur-sm">
{sidePreviewItems.map((item, index) => {
const isActive = item.isSource
? selectedResultIndex < 0
: selectedResultIndex === index - 1;
return (
<button
className={cn(
"group flex h-16 w-16 shrink-0 overflow-hidden rounded-2xl border transition",
isActive
? "border-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_10px_20px_rgba(0,0,0,0.08)]"
: "border-[#e5e5e5] hover:shadow-sm"
)}
key={item.key}
onClick={() =>
setSelectedResultIndex(item.isSource ? -1 : index - 1)
}
title={item.label}
type="button"
>
<img
alt={item.label}
className="h-full w-full object-cover"
src={getDisplayImageUrl(item.url, 220)}
/>
</button>
);
})}
</div>
</div>
) : null}
<div className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100%-2rem)] -translate-x-1/2 flex-col items-center gap-2">
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#666666] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
<span className="inline-flex items-center gap-1.5">
{selectedGeneratedImage ? (
<CheckCircle2 className="h-3.5 w-3.5 text-[#111111]" />
) : null}
{actionHint}
</span>
</div>
{pointsEstimate.chargeable ? (
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#111111] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
预计消耗 {pointsEstimate.cost} 积分
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
? ` · ${pointsEstimate.bucketName}`
: ""}
</div>
) : null}
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-[32px] border border-[#e5e5e5] bg-white p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_18px_38px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<Button
className={cn(
"h-11 min-w-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={isSubmitting || !sourceImageUrl}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
开始增强
</Button>
<Button
className={cn(
"h-11 min-w-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
<div className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pr-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
{isLoading ? (
<div
className={cn(
PANEL_SHELL_CLASS,
"flex items-center gap-2 px-4 py-3 text-[#666666] text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载增强配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-[24px] border border-[#e5e5e5] bg-white px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
配置。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-[24px] border border-[#e5e5e5] bg-[#fafafa] px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
{lastErrorMessage}
</div>
) : null}
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setSettingsOpen}
open={settingsOpen}
>
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<Settings2 className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Studio Setup</div>
<div className={PANEL_SECTION_TITLE_CLASS}>设置</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666] sm:inline-flex">
{formState.model}
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-[#666666] transition-transform",
settingsOpen && "rotate-180"
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue placeholder="选择一个增强预设" />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
{presets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPreset ? (
<div className="rounded-[18px] border border-[#ececec] bg-white px-3 py-2.5">
<div className={cn(PANEL_SECTION_NOTE_CLASS, "line-clamp-2")}>
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
</div>
) : null}
</div>
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
<Label
className={PANEL_SECTION_TITLE_CLASS}
htmlFor="enhance-workspace-prompt"
>
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[84px] resize-none rounded-[20px] bg-white text-sm placeholder:text-[#a5a8b5]`}
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={2}
value={formState.prompt}
/>
</div>
<div className={cn(INNER_PANEL_CLASS, "grid grid-cols-4 gap-2.5")}>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-width"
>
宽度
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-width"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"width",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.width}
/>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-height"
>
高度
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-height"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"height",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.height}
/>
</div>
{config.allowBatchSize ? (
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>候选</Label>
<Select
onValueChange={(value) =>
handleNumericField(
"batchSize",
clampNumber(
Number.parseInt(value, 10) || 1,
1,
config.maxBatchSize
)
)
}
value={String(formState.batchSize)}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
{Array.from(
{ length: config.maxBatchSize },
(_, index) => index + 1
).map((item) => (
<SelectItem key={item} value={String(item)}>
{item} 张
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
value={formState.model}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="Redefine">Redefine</SelectItem>
<SelectItem value="Recovery">Recovery</SelectItem>
<SelectItem value="Recovery V2">Recovery V2</SelectItem>
<SelectItem value="Reimagine">Reimagine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>格式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
output_format: value as FormState["output_format"],
}))
}
value={formState.output_format}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="jpg">JPG</SelectItem>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="webp">WEBP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>区域</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
subject_detection:
value as FormState["subject_detection"],
}))
}
value={formState.subject_detection}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="All">全部</SelectItem>
<SelectItem value="Foreground">前景</SelectItem>
<SelectItem value="Background">背景</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
"seed",
Number.parseInt(event.target.value, 10) || 1337
)
}
type="number"
value={formState.seed}
/>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-scale"
>
放大
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-scale"
max="32"
min="1"
onChange={(event) =>
handleNumericField(
"image_scaling_factor",
clampNumber(
Number.parseFloat(event.target.value) || 1,
1,
32
)
)
}
step="0.5"
type="number"
value={formState.image_scaling_factor}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
COMPACT_TOGGLE_CARD_CLASS,
"bg-white"
)}
>
<span className="text-[#111111] text-xs">启用放大</span>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
upscaling_activated: checked,
}))
}
/>
</div>
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
COMPACT_TOGGLE_CARD_CLASS,
"bg-white"
)}
>
<span className="text-[#111111] text-xs">裁剪铺满</span>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
crop_to_fill: checked,
}))
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{config.enableAdvancedControls ? (
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setAdjustmentsOpen}
open={adjustmentsOpen}
>
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Fine Tune</div>
<div className={PANEL_SECTION_TITLE_CLASS}>调节</div>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-[#666666] transition-transform",
adjustmentsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<ParameterSliderRow
label="锐化"
max={1}
min={0}
onChange={(value) =>
handleNumericField("sharpen", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.sharpen}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="降噪"
max={1}
min={0}
onChange={(value) =>
handleNumericField("denoise", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.denoise}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="细节"
max={1}
min={0}
onChange={(value) =>
handleNumericField("detail", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.detail}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="创造力"
max={6}
min={1}
onChange={(value) =>
handleNumericField(
"creativity",
clampNumber(Math.round(value), 1, 6)
)
}
step={1}
value={formState.creativity}
formatValue={(value) => String(Math.round(value))}
/>
<ParameterSliderRow
label="纹理"
max={5}
min={1}
onChange={(value) =>
handleNumericField(
"texture",
clampNumber(Math.round(value), 1, 5)
)
}
step={1}
value={formState.texture}
formatValue={(value) => String(Math.round(value))}
/>
</CollapsibleContent>
</Collapsible>
) : null}
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setFaceOpen}
open={faceOpen}
>
<div className="flex items-center justify-between gap-3">
<CollapsibleTrigger asChild>
<button
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<ScanFace className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Face Boost</div>
<div className={PANEL_SECTION_TITLE_CLASS}>面部增强</div>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-[#666666] transition-transform",
faceOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
face_enhancement: checked,
}))
}
/>
</div>
<CollapsibleContent className="mt-3 space-y-4">
<ParameterSliderRow
disabled={!formState.face_enhancement}
label="脸部力度"
max={1}
min={0}
onChange={(value) =>
handleNumericField(
"face_enhancement_strength",
clampNumber(value, 0, 1)
)
}
step={0.01}
value={formState.face_enhancement_strength}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
disabled={!formState.face_enhancement}
label="脸部创意"
max={1}
min={0}
onChange={(value) =>
handleNumericField(
"face_enhancement_creativity",
clampNumber(value, 0, 1)
)
}
step={0.01}
value={formState.face_enhancement_creativity}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;