Files
Genarrative/src/components/square-hole-result/SquareHoleResultView.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

1465 lines
48 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.
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Images,
Loader2,
Play,
Plus,
RefreshCw,
Send,
Trash2,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import type { SquareHoleResultDraft } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
PutSquareHoleWorkRequest,
SquareHoleHoleOption,
SquareHoleShapeOption,
SquareHoleWorkProfile,
} from '../../../packages/shared/src/contracts/squareHoleWorks';
import {
publishSquareHoleWork,
regenerateSquareHoleWorkImage,
squareHoleAssetClient,
type SquareHoleHistoryAsset,
type SquareHoleImageAssetKind,
updateSquareHoleWork,
} from '../../services/square-hole-works';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAssetPickerGrid } from '../common/PlatformAssetPickerCard';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatGrid } from '../common/PlatformStatGrid';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import {
PlatformSelectField,
PlatformTextField,
} from '../common/PlatformTextField';
type SquareHoleResultViewProps = {
profile: SquareHoleWorkProfile;
draft?: SquareHoleResultDraft | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaved?: (profile: SquareHoleWorkProfile) => void;
onPublished?: (profile: SquareHoleWorkProfile) => void;
onStartTestRun: (profile: SquareHoleWorkProfile) => void;
};
type SquareHoleAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type SquareHoleResultEditState = {
gameName: string;
summary: string;
tagsText: string;
coverImageSrc: string;
backgroundPrompt: string;
backgroundImageSrc: string;
themeText: string;
twistRule: string;
shapeOptions: SquareHoleShapeOption[];
holeOptions: SquareHoleHoleOption[];
shapeCountText: string;
difficulty: number;
};
type SquareHoleImageSlot = {
kind: 'cover' | 'background' | 'shape' | 'hole';
title: string;
assetKind: SquareHoleImageAssetKind;
shapeOptionId?: string;
holeOptionId?: string;
};
const SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS = 600;
const SQUARE_HOLE_SHAPE_KIND_OPTIONS = [
'square',
'circle',
'triangle',
'star',
'arch',
'diamond',
];
function fallbackHoleOptionId(index: number) {
return `hole-${index + 1}`;
}
function resolveFirstHoleId(holeOptions: SquareHoleHoleOption[]) {
return (
holeOptions.find((option) => option.holeId.trim())?.holeId.trim() ?? ''
);
}
function resolveShapeTargetHoleId(
option: SquareHoleShapeOption,
index: number,
holeOptions: SquareHoleHoleOption[],
) {
const holeIds = holeOptions
.map((holeOption) => holeOption.holeId.trim())
.filter(Boolean);
const currentTarget = option.targetHoleId?.trim() ?? '';
if (holeIds.includes(currentTarget)) {
return currentTarget;
}
return holeIds[index % Math.max(1, holeIds.length)] ?? '';
}
function remapShapeTargetsToHoles(
shapeOptions: SquareHoleShapeOption[],
holeOptions: SquareHoleHoleOption[],
) {
return shapeOptions.map((option, index) => ({
...option,
targetHoleId: resolveShapeTargetHoleId(option, index, holeOptions),
}));
}
function normalizeTags(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function normalizeShapeCount(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 6 && parsed <= 24 ? parsed : null;
}
function createEditState(
profile: SquareHoleWorkProfile,
): SquareHoleResultEditState {
const holeOptions = profile.holeOptions.map((option, index) => {
const holeId = option.holeId.trim() || fallbackHoleOptionId(index);
return {
...option,
holeId,
holeKind: option.holeKind.trim() || holeId,
imagePrompt: option.imagePrompt ?? '',
imageSrc: option.imageSrc ?? null,
};
});
const shapeOptions = remapShapeTargetsToHoles(
profile.shapeOptions.map((option) => ({
...option,
targetHoleId: option.targetHoleId ?? '',
})),
holeOptions,
);
return {
gameName: profile.gameName,
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,
holeOptions,
shapeCountText: String(profile.shapeCount),
difficulty: profile.difficulty,
};
}
function buildSavePayload(
editState: SquareHoleResultEditState,
): PutSquareHoleWorkRequest | null {
const shapeCount = normalizeShapeCount(editState.shapeCountText);
const gameName = editState.gameName.trim();
const themeText = editState.themeText.trim();
const twistRule = editState.twistRule.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
const shapeOptions = editState.shapeOptions
.map((option) => ({
optionId: option.optionId.trim(),
shapeKind: option.shapeKind.trim(),
label: option.label.trim(),
targetHoleId: option.targetHoleId.trim(),
imagePrompt: option.imagePrompt.trim(),
imageSrc: option.imageSrc?.trim() || null,
}))
.filter(
(option) =>
option.optionId &&
option.shapeKind &&
option.label &&
option.targetHoleId &&
option.imagePrompt,
);
const holeOptions = editState.holeOptions
.map((option, index) => {
const holeId = option.holeId.trim() || fallbackHoleOptionId(index);
return {
holeId,
holeKind: holeId,
label: option.label.trim(),
imagePrompt: option.imagePrompt.trim(),
imageSrc: option.imageSrc?.trim() || null,
};
})
.filter((option) => option.holeId && option.label && option.imagePrompt);
const holeIdSet = new Set(holeOptions.map((option) => option.holeId));
const hasInvalidShapeTarget = shapeOptions.some(
(option) => !holeIdSet.has(option.targetHoleId),
);
if (
!gameName ||
!themeText ||
!twistRule ||
!summary ||
tags.length === 0 ||
shapeOptions.length === 0 ||
holeOptions.length === 0 ||
hasInvalidShapeTarget ||
!shapeCount
) {
return null;
}
return {
gameName,
themeText,
twistRule,
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
backgroundPrompt: editState.backgroundPrompt.trim(),
backgroundImageSrc: editState.backgroundImageSrc.trim() || null,
shapeOptions,
holeOptions,
shapeCount,
difficulty: editState.difficulty,
};
}
function buildPublishBlockers(editState: SquareHoleResultEditState) {
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.twistRule.trim() ? [] : ['反差规则不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(normalizeTags(editState.tagsText).length > 0
? []
: ['至少需要 1 个标签。']),
...(editState.shapeOptions.some(
(option) =>
option.optionId.trim() &&
option.shapeKind.trim() &&
option.label.trim() &&
option.targetHoleId.trim() &&
option.imagePrompt.trim(),
)
? []
: ['至少需要 1 个形状选项。']),
...(editState.holeOptions.some(
(option) =>
option.holeId.trim() &&
option.label.trim() &&
option.imagePrompt.trim(),
)
? []
: ['至少需要 1 个洞口选项。']),
...(editState.shapeOptions.every((option) =>
editState.holeOptions.some(
(holeOption) => holeOption.holeId.trim() === option.targetHoleId.trim(),
),
)
? []
: ['每个展示选项都需要绑定一个洞口选项。']),
...(normalizeShapeCount(editState.shapeCountText)
? []
: ['形状数量需要在 6 到 24 之间。']),
];
return [...new Set(blockers)];
}
function readImageAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('请选择图片文件。'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('封面图读取失败,请重试。'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
}
function createShapeOption(
index: number,
holeOptions: SquareHoleHoleOption[],
): 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}`,
targetHoleId: resolveFirstHoleId(holeOptions),
imagePrompt: '主题贴图',
imageSrc: null,
};
}
function createHoleOption(index: number): SquareHoleHoleOption {
const holeId = `hole-${Date.now().toString(36)}-${index}`;
return {
holeId,
holeKind: holeId,
label: `洞口 ${index + 1}`,
imagePrompt: `洞口 ${index + 1}贴图,透明背景,游戏资产`,
imageSrc: null,
};
}
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value || '';
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function resolveImageSlotSrc(
editState: SquareHoleResultEditState,
slot: SquareHoleImageSlot,
) {
if (slot.kind === 'cover') {
return editState.coverImageSrc;
}
if (slot.kind === 'background') {
return editState.backgroundImageSrc;
}
if (slot.kind === 'hole') {
return (
editState.holeOptions
.find((option) => option.holeId === slot.holeOptionId)
?.imageSrc?.trim() || ''
);
}
return (
editState.shapeOptions
.find((option) => option.optionId === slot.shapeOptionId)
?.imageSrc?.trim() || ''
);
}
function applyImageSlotSrc(
current: SquareHoleResultEditState,
slot: SquareHoleImageSlot,
imageSrc: string,
): SquareHoleResultEditState {
if (slot.kind === 'cover') {
return {
...current,
coverImageSrc: imageSrc,
};
}
if (slot.kind === 'background') {
return {
...current,
backgroundImageSrc: imageSrc,
};
}
if (slot.kind === 'hole') {
return {
...current,
holeOptions: current.holeOptions.map((option) =>
option.holeId === slot.holeOptionId
? {
...option,
imageSrc,
}
: option,
),
};
}
return {
...current,
shapeOptions: current.shapeOptions.map((option) =>
option.optionId === slot.shapeOptionId
? {
...option,
imageSrc,
}
: option,
),
};
}
function buildPlayableProfile(
profile: SquareHoleWorkProfile,
editState: SquareHoleResultEditState,
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
}
return {
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
twistRule: payload.twistRule,
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 ?? profile.difficulty,
};
}
function SquareHoleResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: SquareHoleAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<PlatformPillBadge tone="warning" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : autoSaveState === 'saved' ? (
<PlatformPillBadge tone="success" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : autoSaveState === 'error' ? (
<PlatformPillBadge tone="danger" size="xs" className="px-3 py-1">
</PlatformPillBadge>
) : null;
return (
<div className="mb-4 flex items-center justify-between gap-3">
<PlatformActionButton
onClick={onBack}
disabled={isBusy}
tone="ghost"
size="xs"
className="min-h-0 self-start gap-1.5 px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</PlatformActionButton>
{badge}
</div>
);
}
function SquareHoleImageSlotDialog({
currentImageSrc,
canRegenerateImages,
isBusy,
isRegeneratingImages,
slot,
onClose,
onRegenerateImages,
onSelectHistory,
onUpload,
}: {
currentImageSrc: string;
canRegenerateImages: boolean;
isBusy: boolean;
isRegeneratingImages: boolean;
slot: SquareHoleImageSlot;
onClose: () => void;
onRegenerateImages: () => void;
onSelectHistory: (asset: SquareHoleHistoryAsset) => void;
onUpload: (
slot: SquareHoleImageSlot,
event: ChangeEvent<HTMLInputElement>,
) => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [assets, setAssets] = useState<SquareHoleHistoryAsset[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setError(null);
squareHoleAssetClient
.listHistoryAssets({ kind: slot.assetKind, limit: 120 })
.then((nextAssets) => {
if (!cancelled) {
setAssets(nextAssets);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(
loadError instanceof Error
? loadError.message
: '历史图片读取失败。',
);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [slot.assetKind]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[145] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={`${slot.title}查看`}
className="platform-modal-shell platform-remap-surface flex max-h-[min(92vh,46rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
{slot.title}
</div>
<PlatformModalCloseButton
onClick={onClose}
label="关闭"
variant="platformIcon"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(16rem,0.78fr)]">
<div className="space-y-3">
<PlatformMediaFrame
src={currentImageSrc}
alt={slot.title}
fallbackLabel={`${slot.title}占位图`}
fallbackContent={<Images className="h-10 w-10" />}
aspect="standard"
surface="plain"
className="rounded-[1.35rem]"
fallbackClassName="tracking-normal text-[var(--platform-text-soft)]"
/>
<div className="grid gap-2 sm:grid-cols-2">
<PlatformActionButton
asChild="label"
tone="ghost"
size="md"
aria-disabled={isBusy}
className={`min-h-11 cursor-pointer gap-2 px-4 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept="image/*"
className="sr-only"
disabled={isBusy}
onChange={(event) => onUpload(slot, event)}
/>
</PlatformActionButton>
<PlatformActionButton
onClick={onRegenerateImages}
disabled={!canRegenerateImages || isBusy}
tone="secondary"
size="md"
className="min-h-11 gap-2 px-4"
>
{isRegeneratingImages ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
AI生成图片
</PlatformActionButton>
</div>
</div>
<div className="min-h-0 space-y-3">
<PlatformFieldLabel
variant="section"
className="flex items-center gap-2"
>
<Images className="h-3.5 w-3.5" />
</PlatformFieldLabel>
<PlatformAssetPickerGrid
items={assets}
isLoading={isLoading}
error={error}
loadingLabel="读取中..."
emptyLabel="暂无历史图片"
disabled={isBusy}
getKey={(asset) => asset.assetObjectId}
getImageSrc={(asset) => asset.imageSrc}
getImageAlt={(asset) => asset.ownerLabel || '历史图片'}
getTitle={(asset) => asset.ownerLabel || '未记录账号'}
getSubtitle={(asset) => formatHistoryAssetDate(asset.createdAt)}
onSelect={onSelectHistory}
gridClassName="grid max-h-[24rem] grid-cols-2 gap-3 overflow-y-auto pr-1 sm:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3"
/>
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
export function SquareHoleResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: SquareHoleResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [autoSaveState, setAutoSaveState] =
useState<SquareHoleAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isRegeneratingImages, setIsRegeneratingImages] = useState(false);
const [isApplyingHistoryImage, setIsApplyingHistoryImage] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const [activeImageSlot, setActiveImageSlot] =
useState<SquareHoleImageSlot | null>(null);
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
const canSubmit = blockers.length === 0;
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile]);
useEffect(() => {
if (
isApplyingHistoryImage ||
isPublishing ||
isRegeneratingImages ||
isStartingTestRun
) {
return undefined;
}
const payload = buildSavePayload(editState);
if (!payload) {
return undefined;
}
const currentPayload = buildSavePayload(createEditState(profile));
const changed =
!currentPayload ||
payload.gameName !== currentPayload.gameName ||
payload.themeText !== currentPayload.themeText ||
payload.twistRule !== currentPayload.twistRule ||
payload.summary !== currentPayload.summary ||
(payload.coverImageSrc ?? '') !== (currentPayload.coverImageSrc ?? '') ||
(payload.backgroundPrompt ?? '') !==
(currentPayload.backgroundPrompt ?? '') ||
(payload.backgroundImageSrc ?? '') !==
(currentPayload.backgroundImageSrc ?? '') ||
payload.shapeCount !== currentPayload.shapeCount ||
JSON.stringify(payload.shapeOptions ?? []) !==
JSON.stringify(currentPayload.shapeOptions ?? []) ||
JSON.stringify(payload.holeOptions ?? []) !==
JSON.stringify(currentPayload.holeOptions ?? []) ||
payload.tags.length !== currentPayload.tags.length ||
payload.tags.some((tag, index) => tag !== currentPayload.tags[index]);
if (!changed) {
return undefined;
}
setAutoSaveState('saving');
setLocalError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updateSquareHoleWork(profile.profileId, payload)
.then(({ item }) => {
if (cancelled) {
return;
}
setAutoSaveState('saved');
onSaved?.(item);
})
.catch((saveError) => {
if (cancelled) {
return;
}
setAutoSaveState('error');
setLocalError(
saveError instanceof Error ? saveError.message : '自动保存失败。',
);
});
}, SQUARE_HOLE_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [
editState,
isApplyingHistoryImage,
isPublishing,
isRegeneratingImages,
isStartingTestRun,
onSaved,
profile,
]);
const saveStateNow = async (state: SquareHoleResultEditState) => {
const payload = buildSavePayload(state);
if (!payload) {
setLocalError(blockers[0] ?? '请补全作品信息。');
return null;
}
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateSquareHoleWork(profile.profileId, payload);
setAutoSaveState('saved');
onSaved?.(item);
return item;
};
const saveNow = async () => {
return saveStateNow(editState);
};
const handleImageSlotUpload = async (
slot: SquareHoleImageSlot,
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file) {
return;
}
try {
const dataUrl = await readImageAsDataUrl(file);
setEditState((current) => applyImageSlotSrc(current, slot, dataUrl));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '图片读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canSubmit || isStartingTestRun) {
setLocalError(blockers[0] ?? null);
return;
}
setIsStartingTestRun(true);
try {
const savedProfile = await saveNow();
onStartTestRun(savedProfile ?? buildPlayableProfile(profile, editState));
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '启动试玩前保存失败。',
);
} finally {
setIsStartingTestRun(false);
}
};
const handleRegenerateImages = async () => {
if (!activeImageSlot || !canSubmit || isRegeneratingImages) {
setLocalError(blockers[0] ?? null);
return;
}
setIsRegeneratingImages(true);
try {
const savedProfile = await saveNow();
const targetProfile =
savedProfile ?? buildPlayableProfile(profile, editState);
const { item } = await regenerateSquareHoleWorkImage(
targetProfile.profileId,
{
visualAssetSlot: activeImageSlot.kind,
visualAssetOptionId:
activeImageSlot.shapeOptionId ??
activeImageSlot.holeOptionId ??
null,
},
);
setEditState(createEditState(item));
onSaved?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '重生成图片前保存失败。',
);
} finally {
setIsRegeneratingImages(false);
}
};
const handleSelectHistoryImage = async (
slot: SquareHoleImageSlot,
asset: SquareHoleHistoryAsset,
) => {
if (
isApplyingHistoryImage ||
isBusy ||
isPublishing ||
isRegeneratingImages ||
isStartingTestRun
) {
return;
}
const nextState = applyImageSlotSrc(editState, slot, asset.imageSrc);
setEditState(nextState);
setIsApplyingHistoryImage(true);
try {
await saveStateNow(nextState);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '套用历史图片失败。',
);
} finally {
setIsApplyingHistoryImage(false);
}
};
const handlePublish = async () => {
if (!canSubmit || isPublishing) {
setLocalError(blockers[0] ?? null);
return;
}
setIsPublishing(true);
try {
const savedProfile = await saveNow();
const { item } = await publishSquareHoleWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '发布方洞挑战失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy =
isBusy ||
isPublishing ||
isRegeneratingImages ||
isApplyingHistoryImage ||
isStartingTestRun;
const displayError = error ?? localError;
const activeImageSrc = activeImageSlot
? resolveImageSlotSrc(editState, activeImageSlot)
: '';
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<SquareHoleResultHeader
autoSaveState={autoSaveState}
isBusy={busy}
onBack={onBack}
/>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 lg:grid-cols-[minmax(17rem,0.72fr)_minmax(0,1fr)]">
<PlatformSubpanel radius="xl" padding="lg">
<button
type="button"
disabled={busy}
onClick={() =>
setActiveImageSlot({
kind: 'cover',
title: '封面图',
assetKind: 'square_hole_cover_image',
})
}
className={`group block w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_42%_30%,rgba(125,211,252,0.28),transparent_34%),linear-gradient(135deg,rgba(15,23,42,0.16),rgba(20,184,166,0.18))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
aria-label="查看封面图"
>
<PlatformMediaFrame
src={editState.coverImageSrc}
alt=""
fallbackLabel="封面图"
fallbackContent={<ImagePlus className="h-10 w-10" />}
aspect="standard"
surface="none"
className="rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-700"
/>
</button>
<PlatformStatGrid
items={[
{ value: `${editState.shapeCountText || '-'}` },
{
value:
(draft?.publishReady ?? profile.publishReady)
? '可发布'
: '草稿',
},
]}
columns="two"
density="compact"
surface="plain"
className="mt-3"
itemClassName="border-0 bg-white/68"
/>
<button
type="button"
disabled={busy}
onClick={() =>
setActiveImageSlot({
kind: 'background',
title: '背景图',
assetKind: 'square_hole_background_image',
})
}
className={`mt-3 block w-full 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))] text-left ${busy ? 'cursor-not-allowed opacity-70' : ''}`}
aria-label="查看背景图"
>
<PlatformMediaFrame
src={editState.backgroundImageSrc}
alt=""
fallbackLabel="背景图"
fallbackContent={<ImagePlus className="h-8 w-8" />}
aspect="landscape"
surface="none"
className="rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-700"
/>
</button>
</PlatformSubpanel>
<PlatformSubpanel radius="xl" padding="lg">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block sm:col-span-2">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformTextField
value={editState.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: event.target.value })
}
size="lg"
className="mt-2"
/>
</label>
<label className="block sm:col-span-2">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
value={editState.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: event.target.value })
}
className="mt-2"
/>
</label>
<label className="block sm:col-span-2">
<PlatformFieldLabel variant="section"></PlatformFieldLabel>
<PlatformTextField
variant="textarea"
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
size="md"
className="mt-2"
/>
</label>
<label className="block">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformTextField
value={editState.themeText}
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
themeText: event.target.value,
})
}
className="mt-2"
/>
</label>
<label className="block">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformTextField
value={editState.twistRule}
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
twistRule: event.target.value,
})
}
className="mt-2"
/>
</label>
<label className="block sm:col-span-2">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformTextField
value={editState.backgroundPrompt}
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
backgroundPrompt: event.target.value,
})
}
className="mt-2"
/>
</label>
<label className="block">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformTextField
value={editState.shapeCountText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
shapeCountText: event.target.value,
})
}
className="mt-2"
/>
</label>
</div>
</PlatformSubpanel>
<PlatformSubpanel radius="xl" padding="lg" className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformActionButton
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: [
...current.shapeOptions,
createShapeOption(
current.shapeOptions.length,
current.holeOptions,
),
],
}))
}
tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</PlatformActionButton>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.shapeOptions.map((option) => (
<PlatformSubpanel
as="div"
key={option.optionId}
surface="flat"
radius="sm"
padding="sm"
>
<div className="mb-3 flex items-start gap-3">
<PlatformSubpanel
as="button"
disabled={busy}
surface="flat"
radius="sm"
padding="none"
interactive
onClick={() =>
setActiveImageSlot({
kind: 'shape',
title: `${option.label || '形状'}贴图`,
assetKind: 'square_hole_shape_image',
shapeOptionId: option.optionId,
})
}
className="relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden bg-white/82 text-slate-600"
aria-label={`查看${option.label || '形状'}贴图`}
>
<PlatformMediaFrame
src={option.imageSrc}
alt=""
fallbackLabel={`${option.label || '形状'}贴图`}
fallbackContent={<ImagePlus className="h-6 w-6" />}
aspect="square"
surface="none"
className="h-full w-full rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-600"
/>
</PlatformSubpanel>
<div className="min-w-0 flex-1 space-y-2">
<PlatformTextField
aria-label={`${option.label || '形状'}名称`}
value={option.label}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, label: event.target.value }
: entry,
),
}))
}
/>
<PlatformSelectField
aria-label={`${option.label || '形状'}目标洞口`}
value={option.targetHoleId}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, targetHoleId: event.target.value }
: entry,
),
}))
}
>
{editState.holeOptions.map((holeOption, holeIndex) => (
<option
key={holeOption.holeId}
value={holeOption.holeId}
>
{holeOption.label.trim() || `洞口 ${holeIndex + 1}`}
</option>
))}
</PlatformSelectField>
</div>
<PlatformIconButton
disabled={busy || editState.shapeOptions.length <= 1}
onClick={() =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.filter(
(entry) => entry.optionId !== option.optionId,
),
}))
}
label="删除形状选项"
icon={<Trash2 className="h-4 w-4" />}
className="p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
/>
</div>
<PlatformTextField
variant="textarea"
aria-label={`${option.label || '形状'}图片提示词`}
value={option.imagePrompt}
disabled={busy}
rows={2}
size="xs"
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
shapeOptions: current.shapeOptions.map((entry) =>
entry.optionId === option.optionId
? { ...entry, imagePrompt: event.target.value }
: entry,
),
}))
}
/>
</PlatformSubpanel>
))}
</div>
</PlatformSubpanel>
<PlatformSubpanel radius="xl" padding="lg" className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between gap-3">
<PlatformFieldLabel variant="section">
</PlatformFieldLabel>
<PlatformActionButton
disabled={busy}
onClick={() =>
setEditState((current) => ({
...current,
holeOptions: [
...current.holeOptions,
createHoleOption(current.holeOptions.length),
],
}))
}
tone="ghost"
size="xs"
className="min-h-0 gap-1.5 px-3 py-1.5 text-[11px]"
>
<Plus className="h-3.5 w-3.5" />
</PlatformActionButton>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{editState.holeOptions.map((option) => (
<PlatformSubpanel
as="div"
key={option.holeId}
surface="flat"
radius="sm"
padding="sm"
>
<div className="mb-3 flex items-start gap-3">
<PlatformSubpanel
as="button"
disabled={busy}
surface="flat"
radius="sm"
padding="none"
interactive
onClick={() =>
setActiveImageSlot({
kind: 'hole',
title: `${option.label || '洞口'}贴图`,
assetKind: 'square_hole_hole_image',
holeOptionId: option.holeId,
})
}
className="relative grid h-20 w-20 shrink-0 place-items-center overflow-hidden bg-white/82 text-slate-600"
aria-label={`查看${option.label || '洞口'}贴图`}
>
<PlatformMediaFrame
src={option.imageSrc}
alt=""
fallbackLabel={`${option.label || '洞口'}贴图`}
fallbackContent={<ImagePlus className="h-6 w-6" />}
aspect="square"
surface="none"
className="h-full w-full rounded-none bg-transparent"
fallbackClassName="tracking-normal text-slate-600"
/>
</PlatformSubpanel>
<div className="min-w-0 flex-1 space-y-2">
<PlatformTextField
aria-label={`${option.label || '洞口'}名称`}
value={option.label}
disabled={busy}
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, label: event.target.value }
: entry,
),
}))
}
/>
<PlatformTextField
variant="textarea"
aria-label={`${option.label || '洞口'}图片提示词`}
value={option.imagePrompt}
disabled={busy}
rows={2}
size="xs"
density="compact"
onChange={(event) =>
setEditState((current) => ({
...current,
holeOptions: current.holeOptions.map((entry) =>
entry.holeId === option.holeId
? { ...entry, imagePrompt: event.target.value }
: entry,
),
}))
}
/>
</div>
<PlatformIconButton
disabled={busy || editState.holeOptions.length <= 1}
onClick={() =>
setEditState((current) => {
const nextHoleOptions = current.holeOptions.filter(
(entry) => entry.holeId !== option.holeId,
);
return {
...current,
holeOptions: nextHoleOptions,
shapeOptions: remapShapeTargetsToHoles(
current.shapeOptions.map((shapeOption) => ({
...shapeOption,
targetHoleId:
shapeOption.targetHoleId === option.holeId
? (nextHoleOptions[0]?.holeId ?? '')
: shapeOption.targetHoleId,
})),
nextHoleOptions,
),
};
})
}
label="删除洞口选项"
icon={<Trash2 className="h-4 w-4" />}
className="p-2 text-slate-500 hover:bg-white/72 disabled:opacity-40"
/>
</div>
</PlatformSubpanel>
))}
</div>
</PlatformSubpanel>
</div>
</div>
{displayError ? (
<PlatformStatusMessage
tone="error"
surface="platform"
size="md"
className="mt-3 rounded-2xl"
>
{displayError}
</PlatformStatusMessage>
) : null}
<div className="mt-3 flex flex-col gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:flex-row sm:justify-end">
<PlatformActionButton
onClick={handleStartTestRun}
disabled={!canSubmit || busy}
tone="ghost"
size="md"
className="min-h-11 gap-2 px-5"
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</PlatformActionButton>
<PlatformActionButton
onClick={handlePublish}
disabled={!canSubmit || busy}
size="md"
className="min-h-11 gap-2 px-5"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : profile.publicationStatus === 'published' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
{profile.publicationStatus === 'published' ? '更新发布' : '发布'}
</PlatformActionButton>
</div>
{activeImageSlot ? (
<SquareHoleImageSlotDialog
canRegenerateImages={canSubmit}
currentImageSrc={activeImageSrc}
isBusy={busy}
isRegeneratingImages={isRegeneratingImages}
slot={activeImageSlot}
onClose={() => setActiveImageSlot(null)}
onRegenerateImages={handleRegenerateImages}
onSelectHistory={(asset) => {
void handleSelectHistoryImage(activeImageSlot, asset);
}}
onUpload={(slot, event) => {
void handleImageSlotUpload(slot, event);
}}
/>
) : null}
</div>
);
}
export default SquareHoleResultView;