Extend square-hole creation flow with visual asset timeout guard

This commit is contained in:
kdletters
2026-05-05 15:27:09 +08:00
parent 2252afb080
commit 60b667a9d1
30 changed files with 2838 additions and 215 deletions

View File

@@ -4,13 +4,17 @@ import {
ImagePlus,
Loader2,
Play,
Plus,
Send,
Trash2,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
PutSquareHoleWorkRequest,
SquareHoleHoleOption,
SquareHoleShapeOption,
SquareHoleWorkProfile,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import {
@@ -37,13 +41,26 @@ type SquareHoleResultEditState = {
summary: string;
tagsText: string;
coverImageSrc: string;
backgroundPrompt: string;
backgroundImageSrc: string;
themeText: string;
twistRule: string;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCountText: string;
difficultyText: string;
};
const SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS = 600;
const SQUARE_HOLE_SHAPE_KIND_OPTIONS = [
'square',
'circle',
'triangle',
'star',
'arch',
'diamond',
];
const SQUARE_HOLE_HOLE_KIND_OPTIONS = SQUARE_HOLE_SHAPE_KIND_OPTIONS;
function normalizeTags(value: string) {
return [
@@ -78,8 +95,12 @@ function createEditState(
summary: profile.summary,
tagsText: profile.tags.join(''),
coverImageSrc: profile.coverImageSrc?.trim() || '',
backgroundPrompt: profile.backgroundPrompt || '',
backgroundImageSrc: profile.backgroundImageSrc?.trim() || '',
themeText: profile.themeText,
twistRule: profile.twistRule,
shapeOptions: profile.shapeOptions.map((option) => ({ ...option })),
holeOptions: profile.holeOptions.map((option) => ({ ...option })),
shapeCountText: String(profile.shapeCount),
difficultyText: String(profile.difficulty),
};
@@ -95,6 +116,28 @@ function buildSavePayload(
const twistRule = editState.twistRule.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
const shapeOptions = editState.shapeOptions
.map((option) => ({
...option,
optionId: option.optionId.trim(),
shapeKind: option.shapeKind.trim(),
label: option.label.trim(),
imagePrompt: option.imagePrompt.trim(),
imageSrc: option.imageSrc?.trim() || null,
}))
.filter(
(option) =>
option.optionId && option.shapeKind && option.label && option.imagePrompt,
);
const holeOptions = editState.holeOptions
.map((option) => ({
...option,
holeId: option.holeId.trim(),
holeKind: option.holeKind.trim(),
label: option.label.trim(),
bonus: Boolean(option.bonus),
}))
.filter((option) => option.holeId && option.holeKind && option.label);
if (
!gameName ||
@@ -102,6 +145,8 @@ function buildSavePayload(
!twistRule ||
!summary ||
tags.length === 0 ||
shapeOptions.length === 0 ||
holeOptions.length === 0 ||
!shapeCount ||
!difficulty
) {
@@ -115,6 +160,10 @@ function buildSavePayload(
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
backgroundPrompt: editState.backgroundPrompt.trim(),
backgroundImageSrc: editState.backgroundImageSrc.trim() || null,
shapeOptions,
holeOptions,
shapeCount,
difficulty,
};
@@ -129,6 +178,21 @@ function buildPublishBlockers(editState: SquareHoleResultEditState) {
...(normalizeTags(editState.tagsText).length > 0
? []
: ['至少需要 1 个标签。']),
...(editState.shapeOptions.some(
(option) =>
option.optionId.trim() &&
option.shapeKind.trim() &&
option.label.trim() &&
option.imagePrompt.trim(),
)
? []
: ['至少需要 1 个形状选项。']),
...(editState.holeOptions.some(
(option) =>
option.holeId.trim() && option.holeKind.trim() && option.label.trim(),
)
? []
: ['至少需要 1 个洞口选项。']),
...(normalizeShapeCount(editState.shapeCountText)
? []
: ['形状数量需要在 6 到 24 之间。']),
@@ -154,6 +218,31 @@ function readImageAsDataUrl(file: File) {
});
}
function createShapeOption(index: number): SquareHoleShapeOption {
return {
optionId: `shape-${Date.now().toString(36)}-${index}`,
shapeKind:
SQUARE_HOLE_SHAPE_KIND_OPTIONS[
index % SQUARE_HOLE_SHAPE_KIND_OPTIONS.length
] ?? 'square',
label: `形状 ${index + 1}`,
imagePrompt: '主题贴图',
imageSrc: null,
};
}
function createHoleOption(index: number): SquareHoleHoleOption {
return {
holeId: `hole-${Date.now().toString(36)}-${index}`,
holeKind:
SQUARE_HOLE_HOLE_KIND_OPTIONS[
index % SQUARE_HOLE_HOLE_KIND_OPTIONS.length
] ?? 'square',
label: `洞口 ${index + 1}`,
bonus: index === 0,
};
}
function buildPlayableProfile(
profile: SquareHoleWorkProfile,
editState: SquareHoleResultEditState,
@@ -171,6 +260,10 @@ function buildPlayableProfile(
summary: payload.summary,
tags: payload.tags,
coverImageSrc: payload.coverImageSrc,
backgroundPrompt: payload.backgroundPrompt ?? profile.backgroundPrompt,
backgroundImageSrc: payload.backgroundImageSrc,
shapeOptions: payload.shapeOptions ?? profile.shapeOptions,
holeOptions: payload.holeOptions ?? profile.holeOptions,
shapeCount: payload.shapeCount,
difficulty: payload.difficulty,
};
@@ -241,7 +334,7 @@ export function SquareHoleResultView({
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile.profileId, profile.updatedAt]);
}, [profile]);
useEffect(() => {
const payload = buildSavePayload(editState);
@@ -250,14 +343,21 @@ export function SquareHoleResultView({
}
const currentTags = normalizeTags(profile.tags.join(''));
const currentShapeOptions = JSON.stringify(profile.shapeOptions);
const currentHoleOptions = JSON.stringify(profile.holeOptions);
const changed =
payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText ||
payload.twistRule !== profile.twistRule ||
payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
(payload.backgroundPrompt ?? '') !== (profile.backgroundPrompt ?? '') ||
(payload.backgroundImageSrc ?? '') !==
(profile.backgroundImageSrc ?? '') ||
payload.shapeCount !== profile.shapeCount ||
payload.difficulty !== profile.difficulty ||
JSON.stringify(payload.shapeOptions ?? []) !== currentShapeOptions ||
JSON.stringify(payload.holeOptions ?? []) !== currentHoleOptions ||
payload.tags.length !== currentTags.length ||
payload.tags.some((tag, index) => tag !== currentTags[index]);
@@ -330,6 +430,60 @@ export function SquareHoleResultView({
}
};
const handleBackgroundImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => ({
...current,
backgroundImageSrc: dataUrl,
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '背景图读取失败。',
);
}
};
const handleShapeImageChange = async (
optionId: string,
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((option) =>
option.optionId === optionId
? {
...option,
imageSrc: dataUrl,
}
: option,
),
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '形状贴图读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null);
@@ -422,6 +576,31 @@ export function SquareHoleResultView({
{draft?.publishReady ?? profile.publishReady ? '可发布' : '草稿'}
</div>
</div>
<div className="mt-3 aspect-[16/9] overflow-hidden rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(15,23,42,0.12),rgba(34,197,94,0.16))]">
{editState.backgroundImageSrc ? (
<ResolvedAssetImage
src={editState.backgroundImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-slate-700">
<ImagePlus className="h-8 w-8" />
</div>
)}
</div>
<label className="platform-button platform-button--ghost mt-3 flex min-h-10 cursor-pointer items-center justify-center gap-2 px-3 py-2 text-sm">
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={handleBackgroundImageChange}
/>
</label>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
@@ -497,6 +676,23 @@ export function SquareHoleResultView({
/>
</label>
<label className="block sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={editState.backgroundPrompt}
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
backgroundPrompt: event.target.value,
})
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
@@ -534,6 +730,242 @@ export function SquareHoleResultView({
</label>
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<button
type="button"
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: [
...current.shapeOptions,
createShapeOption(current.shapeOptions.length),
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.shapeOptions.map((option) => (
<div
key={option.optionId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="mb-3 flex items-start gap-3">
<label className="relative grid h-20 w-20 shrink-0 cursor-pointer place-items-center overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/82 text-slate-600">
{option.imageSrc ? (
<ResolvedAssetImage
src={option.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<ImagePlus className="h-6 w-6" />
)}
<input
type="file"
accept="image/*"
className="sr-only"
disabled={busy}
onChange={(event) => {
void handleShapeImageChange(option.optionId, event);
}}
/>
</label>
<div className="min-w-0 flex-1 space-y-2">
<input
value={option.label}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, label: event.target.value }
: entry,
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
<select
value={option.shapeKind}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, shapeKind: event.target.value }
: entry,
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
{SQUARE_HOLE_SHAPE_KIND_OPTIONS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
</div>
<button
type="button"
disabled={busy || editState.shapeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.filter(
(entry) => entry.optionId !== option.optionId,
),
}))
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除形状选项"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<textarea
value={option.imagePrompt}
disabled={busy}
rows={2}
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, imagePrompt: event.target.value }
: entry,
),
}))
}
className="w-full resize-none rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm leading-5 text-[var(--platform-text-strong)] outline-none"
/>
</div>
))}
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5 lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<button
type="button"
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
holeOptions: [
...current.holeOptions,
createHoleOption(current.holeOptions.length),
],
}))
}
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.holeOptions.map((option) => (
<div
key={option.holeId}
className="rounded-[1.1rem] border border-[var(--platform-subpanel-border)] bg-white/58 p-3"
>
<div className="grid gap-2">
<input
value={option.label}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, label: event.target.value }
: entry,
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
<select
value={option.holeKind}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, holeKind: event.target.value }
: entry,
),
}))
}
className="w-full rounded-[0.85rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-2 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
>
{SQUARE_HOLE_HOLE_KIND_OPTIONS.map((kind) => (
<option key={kind} value={kind}>
{kind}
</option>
))}
</select>
<div className="flex items-center justify-between gap-3">
<label className="inline-flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]">
<input
type="checkbox"
checked={option.bonus}
disabled={busy}
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, bonus: event.target.checked }
: entry,
),
}))
}
className="h-4 w-4"
/>
</label>
<button
type="button"
disabled={busy || editState.holeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.filter(
(entry) => entry.holeId !== option.holeId,
),
}))
}
className="rounded-full p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
aria-label="删除洞口选项"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
</section>
</div>
</div>