This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -1,6 +1,7 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
@@ -62,11 +63,79 @@ type PuzzleImageCropState = {
imageSize: { width: number; height: number };
cropX: number;
cropY: number;
scale: number;
cropSize: number;
error: string | null;
isSaving: boolean;
};
type PuzzleCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type PuzzleCropDragSnapshot = {
pointerId: number;
handle: PuzzleCropDragHandle;
clientX: number;
clientY: number;
cropRect: { x: number; y: number; size: number };
previewWidth: number;
previewHeight: number;
};
const PUZZLE_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<PuzzleCropDragHandle, 'move'>;
label: string;
className: string;
}> = [
{
handle: 'northWest',
label: '拖拽左上角裁剪边界',
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'north',
label: '拖拽上边裁剪边界',
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
},
{
handle: 'northEast',
label: '拖拽右上角裁剪边界',
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'east',
label: '拖拽右边裁剪边界',
className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
},
{
handle: 'southEast',
label: '拖拽右下角裁剪边界',
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
},
{
handle: 'south',
label: '拖拽下边裁剪边界',
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
},
{
handle: 'southWest',
label: '拖拽左下角裁剪边界',
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
},
{
handle: 'west',
label: '拖拽左边裁剪边界',
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
},
];
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
@@ -125,73 +194,221 @@ function resolveInitialFormState(
};
}
function clampPuzzleImageCrop(
function clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getPuzzleCropSizeBounds(imageSize: { width: number; height: number }) {
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
return { minSize, maxSize };
}
function clampPuzzleImageCropRect(
imageSize: { width: number; height: number },
scale: number,
crop: { x: number; y: number },
crop: { x: number; y: number; size: number },
) {
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
const maxCropX = Math.max(0, imageSize.width - cropSize);
const maxCropY = Math.max(0, imageSize.height - cropSize);
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
const size = clampNumber(crop.size, minSize, maxSize);
return {
x: Math.max(0, Math.min(maxCropX, crop.x)),
y: Math.max(0, Math.min(maxCropY, crop.y)),
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
size,
};
}
function buildPuzzleCropPreviewStyle(
crop: { x: number; y: number; size: number },
imageSize: { width: number; height: number },
) {
return {
left: `${(crop.x / imageSize.width) * 100}%`,
top: `${(crop.y / imageSize.height) * 100}%`,
width: `${(crop.size / imageSize.width) * 100}%`,
height: `${(crop.size / imageSize.height) * 100}%`,
} satisfies CSSProperties;
}
function resizePuzzleCropRectFromHandle(
snapshot: PuzzleCropDragSnapshot,
deltaX: number,
deltaY: number,
imageSize: { width: number; height: number },
) {
const start = snapshot.cropRect;
const startRight = start.x + start.size;
const startBottom = start.y + start.size;
const startCenterX = start.x + start.size / 2;
const startCenterY = start.y + start.size / 2;
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
const chooseSize = (sizeFromX: number, sizeFromY: number) => {
const xDistance = Math.abs(sizeFromX - start.size);
const yDistance = Math.abs(sizeFromY - start.size);
return xDistance >= yDistance ? sizeFromX : sizeFromY;
};
const clampSize = (size: number, maxByAnchor = maxSize) =>
clampNumber(size, minSize, Math.max(minSize, Math.min(maxSize, maxByAnchor)));
if (snapshot.handle === 'move') {
return clampPuzzleImageCropRect(imageSize, {
...start,
x: start.x + deltaX,
y: start.y + deltaY,
});
}
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
const isEast = snapshot.handle === 'east';
const anchorX = isEast ? start.x : startRight;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByCenterY =
2 * Math.min(startCenterY, imageSize.height - startCenterY);
const size = clampSize(
start.size + (isEast ? deltaX : -deltaX),
Math.min(maxByAnchorX, maxByCenterY),
);
return clampPuzzleImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: startCenterY - size / 2,
size,
});
}
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
const isSouth = snapshot.handle === 'south';
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const maxByCenterX =
2 * Math.min(startCenterX, imageSize.width - startCenterX);
const size = clampSize(
start.size + (isSouth ? deltaY : -deltaY),
Math.min(maxByAnchorY, maxByCenterX),
);
return clampPuzzleImageCropRect(imageSize, {
x: startCenterX - size / 2,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
const isEast = snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
const isSouth = snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
const anchorX = isEast ? start.x : startRight;
const anchorY = isSouth ? start.y : startBottom;
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
const sizeFromX = start.size + (isEast ? deltaX : -deltaX);
const sizeFromY = start.size + (isSouth ? deltaY : -deltaY);
const size = clampSize(
chooseSize(sizeFromX, sizeFromY),
Math.min(maxByAnchorX, maxByAnchorY),
);
return clampPuzzleImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
function PuzzleImageCropModal({
state,
onScaleChange,
onCropChange,
onCropRectChange,
onClose,
onSubmit,
}: {
state: PuzzleImageCropState;
onScaleChange: (value: number) => void;
onCropChange: (nextCrop: { x: number; y: number }) => void;
onCropRectChange: (nextCrop: { x: number; y: number; size: number }) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragStartRef = useRef<{
pointerId: number;
clientX: number;
clientY: number;
cropX: number;
cropY: number;
} | null>(null);
const [isDragging, setIsDragging] = useState(false);
const cropSize = Math.min(state.imageSize.width, state.imageSize.height) /
state.scale;
const maxCropX = Math.max(0, state.imageSize.width - cropSize);
const maxCropY = Math.max(0, state.imageSize.height - cropSize);
const backgroundSize = `${(state.imageSize.width / cropSize) * 100}% ${(state.imageSize.height / cropSize) * 100}%`;
const backgroundPosition = `${maxCropX > 0 ? (state.cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (state.cropY / maxCropY) * 100 : 50}%`;
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current;
const dragSnapshotRef = useRef<PuzzleCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<PuzzleCropDragHandle | null>(null);
const cropRect = useMemo(
() =>
clampPuzzleImageCropRect(state.imageSize, {
x: state.cropX,
y: state.cropY,
size: state.cropSize,
}),
[state.cropSize, state.cropX, state.cropY, state.imageSize],
);
const previewStyle = useMemo(
() => buildPuzzleCropPreviewStyle(cropRect, state.imageSize),
[cropRect, state.imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${state.imageSize.width} / ${state.imageSize.height}`,
width: `min(100%, calc(min(52vh, 22rem) * ${
state.imageSize.width / Math.max(1, state.imageSize.height)
}))`,
}) satisfies CSSProperties,
[state.imageSize],
);
const beginCropDrag = (
handle: PuzzleCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (state.isSaving) {
return;
}
const preview = previewRef.current;
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
if (!preview) {
return;
}
const rect = preview.getBoundingClientRect();
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
onCropChange({
x:
dragStart.cropX -
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
y:
dragStart.cropY -
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
});
dragSnapshotRef.current = {
pointerId: event.pointerId,
handle,
clientX: event.clientX,
clientY: event.clientY,
cropRect,
previewWidth: rect.width,
previewHeight: rect.height,
};
setActiveDragHandle(handle);
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (dragStartRef.current?.pointerId === event.pointerId) {
dragStartRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
const snapshot = dragSnapshotRef.current;
if (!snapshot || snapshot.pointerId !== event.pointerId) {
return;
}
const deltaX =
((event.clientX - snapshot.clientX) * state.imageSize.width) /
Math.max(1, snapshot.previewWidth);
const deltaY =
((event.clientY - snapshot.clientY) * state.imageSize.height) /
Math.max(1, snapshot.previewHeight);
onCropRectChange(
resizePuzzleCropRectFromHandle(snapshot, deltaX, deltaY, state.imageSize),
);
};
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
return;
}
dragSnapshotRef.current = null;
setActiveDragHandle(null);
event.currentTarget.releasePointerCapture(event.pointerId);
};
return (
@@ -218,85 +435,53 @@ function PuzzleImageCropModal({
<div className="px-5 py-5">
<div
ref={previewRef}
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.2rem] border border-white/12 bg-cover bg-center"
style={{
backgroundImage: `url("${state.source}")`,
backgroundSize,
backgroundPosition,
cursor: isDragging ? 'grabbing' : 'grab',
touchAction: 'none',
}}
role="img"
aria-label="拼图图片裁剪预览"
onPointerDown={(event) => {
dragStartRef.current = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
cropX: state.cropX,
cropY: state.cropY,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={updateDragCrop}
onPointerUp={stopDragging}
onPointerCancel={stopDragging}
/>
<div className="mt-5 space-y-4">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="1"
max="3"
step="0.01"
value={state.scale}
onChange={(event) => onScaleChange(Number(event.target.value))}
className="w-full"
/>
</label>
<div className="grid grid-cols-2 gap-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropX}
step="1"
value={Math.min(state.cropX, maxCropX)}
onChange={(event) =>
onCropChange({
x: Number(event.target.value),
y: state.cropY,
})
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
style={editorPreviewStyle}
aria-label="拼图图片裁剪操作区"
>
<img
src={state.source}
alt="拼图图片裁剪预览"
draggable={false}
className="h-full w-full object-fill"
/>
<div
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
}`}
style={previewStyle}
onPointerDown={(event) => beginCropDrag('move', event)}
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
/>
<div
className="pointer-events-none absolute border border-white/70"
style={previewStyle}
>
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
</div>
<div className="pointer-events-none absolute" style={previewStyle}>
{PUZZLE_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={state.isSaving}
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 ${handleConfig.className}`}
onPointerDown={(event) =>
beginCropDrag(handleConfig.handle, event)
}
className="w-full"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
</span>
<input
type="range"
min="0"
max={maxCropY}
step="1"
value={Math.min(state.cropY, maxCropY)}
onChange={(event) =>
onCropChange({
x: state.cropX,
y: Number(event.target.value),
})
}
className="w-full"
/>
</label>
onPointerMove={updateCropDrag}
onPointerUp={stopCropDrag}
onPointerCancel={stopCropDrag}
>
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)]" />
</button>
))}
</div>
</div>
{state.error ? (
@@ -350,6 +535,8 @@ export function PuzzleAgentWorkspace({
null,
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
@@ -378,6 +565,7 @@ export function PuzzleAgentWorkspace({
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
setIsRemoveImageConfirmOpen(false);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
@@ -466,7 +654,7 @@ export function PuzzleAgentWorkspace({
},
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
scale: 1,
cropSize,
error: null,
isSaving: false,
});
@@ -480,6 +668,7 @@ export function PuzzleAgentWorkspace({
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
@@ -489,39 +678,17 @@ export function PuzzleAgentWorkspace({
}
};
const updateCropState = (nextCrop: { x: number; y: number }) => {
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
setCropState((current) => {
if (!current) {
return current;
}
const clamped = clampPuzzleImageCrop(
current.imageSize,
current.scale,
nextCrop,
);
const clamped = clampPuzzleImageCropRect(current.imageSize, nextCrop);
return {
...current,
cropX: clamped.x,
cropY: clamped.y,
};
});
};
const updateCropScale = (nextScale: number) => {
setCropState((current) => {
if (!current) {
return current;
}
const scale = Math.max(1, Math.min(3, nextScale || 1));
const clamped = clampPuzzleImageCrop(current.imageSize, scale, {
x: current.cropX,
y: current.cropY,
});
return {
...current,
scale,
cropX: clamped.x,
cropY: clamped.y,
cropSize: clamped.size,
};
});
};
@@ -539,16 +706,11 @@ export function PuzzleAgentWorkspace({
});
try {
const cropSize =
Math.min(
currentCropState.imageSize.width,
currentCropState.imageSize.height,
) / currentCropState.scale;
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropX,
cropY: currentCropState.cropY,
cropSize,
cropSize: currentCropState.cropSize,
});
setFormState((current) => ({
...current,
@@ -557,6 +719,7 @@ export function PuzzleAgentWorkspace({
}));
setCropState(null);
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
} catch (cropError) {
setCropState({
...currentCropState,
@@ -600,14 +763,24 @@ export function PuzzleAgentWorkspace({
candidateCount: 1,
});
};
const confirmRemoveReferenceImage = () => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
};
const pictureDescriptionLabel = formState.referenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述';
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="platform-remap-surface puzzle-agent-workspace mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<div className="mb-4 flex items-center justify-between gap-3">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
@@ -622,11 +795,11 @@ export function PuzzleAgentWorkspace({
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
{title ? (
<div className="mb-5">
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-4xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
@@ -636,101 +809,123 @@ export function PuzzleAgentWorkspace({
</div>
) : null}
<section className="overflow-visible">
<section className="puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
<div
className={`grid gap-3 sm:gap-4 ${
className={`puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
formState.aiRedraw
? 'lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'lg:grid-cols-1'
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'flex flex-col lg:grid lg:grid-cols-1'
}`}
>
<div className={`min-w-0 ${isBusy ? 'opacity-55' : ''}`}>
<div className="mb-2 text-sm font-black text-[var(--platform-text-strong)]">
<div
className={`puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${isBusy ? 'opacity-55' : ''}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="puzzle-image-upload-card relative aspect-square w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_16px_34px_rgba(222,82,124,0.12)] transition">
<input
id="puzzle-image-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传拼图图片"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
<label
htmlFor="puzzle-image-upload-input"
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'
}
>
<span className="sr-only">
{formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'}
</span>
</label>
{formState.referenceImageSrc ? (
<img
src={formState.referenceImageSrc}
alt="拼图图片"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
<div className="puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
<input
id="puzzle-image-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传拼图图片"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-16 w-16 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-7 w-7 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
{formState.referenceImageSrc ? (
<label className="absolute right-3 top-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={formState.aiRedraw}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
aiRedraw: event.target.checked,
}))
}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
<label
htmlFor="puzzle-image-upload-input"
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'
}
>
<span className="sr-only">
{formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'}
</span>
</label>
) : null}
{formState.referenceImageSrc ? (
<img
src={formState.referenceImageSrc}
alt="拼图图片"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
{formState.referenceImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={formState.aiRedraw}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
aiRedraw: event.target.checked,
}))
}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null}
{formState.referenceImageSrc ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute right-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
aria-label="移除拼图图片"
title="移除拼图图片"
>
<Trash2 className="h-4 w-4" />
</button>
) : (
<label
htmlFor="puzzle-image-upload-input"
className={`absolute bottom-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
</label>
)}
{formState.referenceImageSrc ? null : (
<div className="pointer-events-none absolute bottom-16 left-4 right-4 z-10 text-center text-[11px] font-semibold leading-4 text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<label
htmlFor="puzzle-image-upload-input"
className={`mt-2 block text-center text-sm font-black text-[var(--platform-text-strong)] transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
</label>
</div>
{formState.aiRedraw ? (
<label className="block min-h-0">
<label className="block shrink-0 lg:min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
{pictureDescriptionLabel}
</span>
@@ -746,7 +941,7 @@ export function PuzzleAgentWorkspace({
pictureDescription: event.target.value,
}))
}
className="min-h-[clamp(5rem,15svh,7rem)] w-full resize-none rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[8.5rem] lg:min-h-[10.5rem]"
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={pictureDescriptionLabel}
/>
<PuzzleImageModelPicker
@@ -764,33 +959,7 @@ export function PuzzleAgentWorkspace({
) : null}
</div>
<div className="mt-3 space-y-3">
{formState.referenceImageSrc ? (
<div className="flex items-center justify-end">
<button
type="button"
disabled={isBusy}
onClick={() => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
}}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-xs"
aria-label="移除拼图图片"
title="移除拼图图片"
>
<span className="inline-flex items-center gap-1.5">
<X className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
<div className="mt-2 shrink-0 space-y-3">
{referenceImageError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{referenceImageError}
@@ -805,17 +974,17 @@ export function PuzzleAgentWorkspace({
</section>
</div>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>稿</span>
<span>稿</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
@@ -827,14 +996,49 @@ export function PuzzleAgentWorkspace({
{cropState ? (
<PuzzleImageCropModal
state={cropState}
onScaleChange={updateCropScale}
onCropChange={updateCropState}
onCropRectChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {
void applyCropState();
}}
/>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-image-remove-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-image-remove-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={confirmRemoveReferenceImage}
className="platform-button platform-button--primary justify-center"
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}