新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件 迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome 补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
1465 lines
48 KiB
TypeScript
1465 lines
48 KiB
TypeScript
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;
|