This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -1,5 +1,12 @@
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import {
type ChangeEvent,
type PointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
@@ -7,8 +14,11 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
@@ -26,6 +36,8 @@ type PuzzleAgentWorkspaceProps = {
onCreateFromForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
onAutoSaveForm?: (payload: CreatePuzzleAgentSessionRequest) => void;
initialFormPayload?: CreatePuzzleAgentSessionRequest | null;
showBackButton?: boolean;
title?: string | null;
};
type PuzzleFormState = {
@@ -33,6 +45,7 @@ type PuzzleFormState = {
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
aiRedraw: boolean;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
@@ -40,21 +53,42 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
type PuzzleImageCropState = {
source: string;
label: string;
imageSize: { width: number; height: number };
cropX: number;
cropY: number;
scale: number;
error: string | null;
isSaving: boolean;
};
function resolveInitialFormState(
session: PuzzleAgentSessionSnapshot | null,
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
): PuzzleFormState {
const shouldTreatEmptyPayloadAsFreshForm =
!session &&
Boolean(initialFormPayload) &&
Object.keys(initialFormPayload ?? {}).length === 0;
if (shouldTreatEmptyPayloadAsFreshForm) {
return EMPTY_FORM_STATE;
}
const formDraft = session?.draft?.formDraft;
if (formDraft) {
return {
pictureDescription: formDraft.pictureDescription ?? '',
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
? '已选择拼图图片'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
};
}
@@ -66,9 +100,10 @@ function resolveInitialFormState(
'',
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
? '已选择拼图图片'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
};
}
@@ -86,9 +121,212 @@ function resolveInitialFormState(
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
}
function clampPuzzleImageCrop(
imageSize: { width: number; height: number },
scale: number,
crop: { x: number; y: 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);
return {
x: Math.max(0, Math.min(maxCropX, crop.x)),
y: Math.max(0, Math.min(maxCropY, crop.y)),
};
}
function PuzzleImageCropModal({
state,
onScaleChange,
onCropChange,
onClose,
onSubmit,
}: {
state: PuzzleImageCropState;
onScaleChange: (value: number) => void;
onCropChange: (nextCrop: { x: number; y: 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 preview = previewRef.current;
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
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,
});
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (dragStartRef.current?.pointerId === event.pointerId) {
dragStartRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<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-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="puzzle-image-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭拼图图片裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<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="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>
</div>
</div>
{state.error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{state.error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={state.isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{state.isSaving ? '裁剪中' : '应用'}
</button>
</div>
</div>
</div>
</div>
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
@@ -102,6 +340,8 @@ export function PuzzleAgentWorkspace({
onCreateFromForm,
onAutoSaveForm,
initialFormPayload = null,
showBackButton = true,
title = '想做个什么玩法?',
}: PuzzleAgentWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() =>
resolveInitialFormState(session, initialFormPayload),
@@ -109,9 +349,7 @@ export function PuzzleAgentWorkspace({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const [selectedTemplateId, setSelectedTemplateId] = useState(
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
@@ -139,18 +377,23 @@ export function PuzzleAgentWorkspace({
appliedInitialFormKeyRef.current = nextInitialFormKey;
setFormState(resolveInitialFormState(session, initialFormPayload));
setReferenceImageError(null);
setCropState(null);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
const canSubmit = Boolean(pictureDescription) && !isBusy;
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
const autosavePayload = useMemo(
() => ({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
[
formState.aiRedraw,
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
@@ -158,6 +401,8 @@ export function PuzzleAgentWorkspace({
);
const autosaveSignature = JSON.stringify([
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
@@ -209,35 +454,119 @@ export function PuzzleAgentWorkspace({
}
try {
const dataUrl = await readPuzzleReferenceImageAsDataUrl(file);
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const cropSize = Math.min(uploadImage.width, uploadImage.height);
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
imageSize: {
width: uploadImage.width,
height: uploadImage.height,
},
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
scale: 1,
error: null,
isSaving: false,
});
setReferenceImageError(null);
return;
}
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: file.name.trim() || '本地参考图',
referenceImageSrc: uploadImage.dataUrl,
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
: '拼图图片读取失败,请重试。',
);
}
};
const applyTemplatePrompt = (templateId: string) => {
const template = PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === templateId,
);
if (!template) {
const updateCropState = (nextCrop: { x: number; y: number }) => {
setCropState((current) => {
if (!current) {
return current;
}
const clamped = clampPuzzleImageCrop(
current.imageSize,
current.scale,
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,
};
});
};
const applyCropState = async () => {
const currentCropState = cropState;
if (!currentCropState) {
return;
}
setSelectedTemplateId(template.id);
setFormState((current) => ({
...current,
pictureDescription: template.prompt,
}));
setCropState({
...currentCropState,
isSaving: true,
error: null,
});
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,
});
setFormState((current) => ({
...current,
referenceImageSrc: dataUrl,
referenceImageLabel: currentCropState.label,
}));
setCropState(null);
setReferenceImageError(null);
} catch (cropError) {
setCropState({
...currentCropState,
isSaving: false,
error:
cropError instanceof Error
? cropError.message
: '拼图图片裁剪失败,请重试。',
});
}
};
const submitForm = () => {
@@ -245,11 +574,15 @@ export function PuzzleAgentWorkspace({
return;
}
const payloadPictureDescription = formState.aiRedraw
? pictureDescription
: pictureDescription || formState.referenceImageLabel || '上传拼图图片';
const payload = {
seedText: pictureDescription,
pictureDescription,
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
if (!session && onCreateFromForm) {
@@ -259,161 +592,181 @@ export function PuzzleAgentWorkspace({
onExecuteAction({
action: 'compile_puzzle_draft',
promptText: pictureDescription,
pictureDescription,
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
});
};
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="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
{showBackButton ? (
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
<span className="text-xs font-black text-[var(--platform-text-soft)]">
Template
</span>
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
{PUZZLE_CREATION_TEMPLATES.find(
(item) => item.id === selectedTemplateId,
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
{title ? (
<div className="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">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
<div
className="flex gap-3 overflow-x-auto pb-2"
aria-label="拼图创作模板"
>
{PUZZLE_CREATION_TEMPLATES.map((template) => {
const selected = template.id === selectedTemplateId;
return (
<button
key={template.id}
type="button"
disabled={isBusy}
onClick={() => applyTemplatePrompt(template.id)}
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
selected
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-pressed={selected}
aria-label={`${template.title}模板`}
>
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
<img
src={template.imageSrc}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
</span>
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
{template.title}
</span>
{selected ? (
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
</span>
) : null}
</button>
);
})}
</div>
</div>
) : null}
<div className="mt-4 space-y-4">
<label
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span>
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<label className="block">
<span className="sr-only"></span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={10}
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] 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-[20rem]"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
<section className="overflow-visible">
<div
className={`grid gap-3 sm:gap-4 ${
formState.aiRedraw
? 'lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: '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>
</label>
{formState.referenceImageSrc ? (
<div className="flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<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="h-full w-full object-cover"
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-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'
}`}
/>
</span>
</label>
) : null}
</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">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
{pictureDescriptionLabel}
</span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={2}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
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]"
aria-label={pictureDescriptionLabel}
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{formState.referenceImageLabel || '已选择参考图'}
</div>
</div>
</label>
) : null}
</div>
<div className="mt-3 space-y-3">
{formState.referenceImageSrc ? (
<div className="flex items-center justify-end">
<button
type="button"
disabled={isBusy}
@@ -422,14 +775,18 @@ export function PuzzleAgentWorkspace({
...current,
referenceImageSrc: '',
referenceImageLabel: '',
aiRedraw: true,
}));
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-xs"
aria-label="移除拼图图片"
title="移除拼图图片"
>
<X className="h-4 w-4" />
<span className="inline-flex items-center gap-1.5">
<X className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
@@ -459,12 +816,25 @@ export function PuzzleAgentWorkspace({
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
) : null}
</span>
</button>
</div>
{cropState ? (
<PuzzleImageCropModal
state={cropState}
onScaleChange={updateCropScale}
onCropChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {
void applyCropState();
}}
/>
) : null}
</div>
);
}