Integrate Match3D Q1 flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-01 13:53:59 +08:00
parent 375f7493a3
commit df24467e1d
24 changed files with 2089 additions and 361 deletions

View File

@@ -0,0 +1,588 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
Send,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
import type {
Match3DWorkProfile,
PutMatch3DWorkRequest,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
publishMatch3DWork,
updateMatch3DWork,
} from '../../services/match3d-works';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type Match3DResultViewProps = {
profile: Match3DWorkProfile;
draft?: Match3DResultDraft | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaved?: (profile: Match3DWorkProfile) => void;
onPublished?: (profile: Match3DWorkProfile) => void;
onStartTestRun: (profile: Match3DWorkProfile) => void;
};
type Match3DAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type Match3DResultEditState = {
gameName: string;
summary: string;
tagsText: string;
coverImageSrc: string;
themeText: string;
clearCountText: string;
difficultyText: string;
};
const MATCH3D_MIN_TAG_COUNT = 3;
const MATCH3D_MAX_TAG_COUNT = 6;
const MATCH3D_AUTOSAVE_DEBOUNCE_MS = 600;
function normalizeTags(value: string) {
return [
...new Set(
value
.split(/[\n,]/u)
.map((entry) => entry.trim())
.filter(Boolean),
),
];
}
function normalizePositiveInteger(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function normalizeDifficulty(value: string) {
const parsed = Number.parseInt(value.trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 10
? parsed
: null;
}
function createEditState(profile: Match3DWorkProfile): Match3DResultEditState {
return {
gameName: profile.gameName,
summary: profile.summary,
tagsText: profile.tags.join(''),
coverImageSrc:
profile.coverImageSrc?.trim() || profile.referenceImageSrc?.trim() || '',
themeText: profile.themeText,
clearCountText: String(profile.clearCount),
difficultyText: String(profile.difficulty),
};
}
function buildSavePayload(
editState: Match3DResultEditState,
): PutMatch3DWorkRequest | null {
const clearCount = normalizePositiveInteger(editState.clearCountText);
const difficulty = normalizeDifficulty(editState.difficultyText);
const gameName = editState.gameName.trim();
const themeText = editState.themeText.trim();
const summary = editState.summary.trim();
const tags = normalizeTags(editState.tagsText);
if (!gameName || !themeText || !summary || !clearCount || !difficulty) {
return null;
}
return {
gameName,
themeText,
summary,
tags,
coverImageSrc: editState.coverImageSrc.trim() || null,
clearCount,
difficulty,
};
}
function buildPublishBlockers(editState: Match3DResultEditState) {
const tags = normalizeTags(editState.tagsText);
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(editState.coverImageSrc.trim() ? [] : ['封面图不能为空。']),
...(tags.length >= MATCH3D_MIN_TAG_COUNT && tags.length <= MATCH3D_MAX_TAG_COUNT
? []
: [`标签数量需要在 ${MATCH3D_MIN_TAG_COUNT}${MATCH3D_MAX_TAG_COUNT} 个之间。`]),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
return [...new Set(blockers)];
}
function buildTestRunBlockers(editState: Match3DResultEditState) {
const blockers = [
...(editState.gameName.trim() ? [] : ['游戏名称不能为空。']),
...(editState.themeText.trim() ? [] : ['题材主题不能为空。']),
...(editState.summary.trim() ? [] : ['简介不能为空。']),
...(normalizePositiveInteger(editState.clearCountText)
? []
: ['需要消除次数必须为正整数。']),
...(normalizeDifficulty(editState.difficultyText)
? []
: ['难度必须为 1 到 10。']),
];
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 buildPlayableProfile(
profile: Match3DWorkProfile,
editState: Match3DResultEditState,
) {
const payload = buildSavePayload(editState);
if (!payload) {
return profile;
}
return {
...profile,
gameName: payload.gameName,
themeText: payload.themeText ?? profile.themeText,
summary: payload.summary,
tags: payload.tags,
coverImageSrc: payload.coverImageSrc,
clearCount: payload.clearCount,
difficulty: payload.difficulty,
};
}
function Match3DResultHeader({
autoSaveState,
isBusy,
onBack,
}: {
autoSaveState: Match3DAutoSaveState;
isBusy: boolean;
onBack: () => void;
}) {
const badge =
autoSaveState === 'saving' ? (
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'saved' ? (
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
</div>
) : autoSaveState === 'error' ? (
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
</div>
) : null;
return (
<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>
{badge}
</div>
);
}
export function Match3DResultView({
profile,
draft = null,
isBusy = false,
error = null,
onBack,
onSaved,
onPublished,
onStartTestRun,
}: Match3DResultViewProps) {
const [editState, setEditState] = useState(() => createEditState(profile));
const [autoSaveState, setAutoSaveState] =
useState<Match3DAutoSaveState>('idle');
const [localError, setLocalError] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
const blockers = useMemo(() => buildPublishBlockers(editState), [editState]);
const testRunBlockers = useMemo(
() => buildTestRunBlockers(editState),
[editState],
);
const canStartTestRun = testRunBlockers.length === 0;
const canSubmit = blockers.length === 0;
const totalItemCount =
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
3;
useEffect(() => {
setEditState(createEditState(profile));
setAutoSaveState('idle');
setLocalError(null);
}, [profile.profileId, profile.updatedAt]);
useEffect(() => {
const payload = buildSavePayload(editState);
if (!payload) {
return undefined;
}
const currentTags = normalizeTags(profile.tags.join(''));
const nextTags = payload.tags;
const changed =
payload.gameName !== profile.gameName ||
payload.themeText !== profile.themeText ||
payload.summary !== profile.summary ||
(payload.coverImageSrc ?? '') !== (profile.coverImageSrc ?? '') ||
payload.clearCount !== profile.clearCount ||
payload.difficulty !== profile.difficulty ||
nextTags.length !== currentTags.length ||
nextTags.some((tag, index) => tag !== currentTags[index]);
if (!changed) {
return undefined;
}
setAutoSaveState('saving');
setLocalError(null);
let cancelled = false;
const timer = window.setTimeout(() => {
void updateMatch3DWork(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 : '自动保存失败。',
);
});
}, MATCH3D_AUTOSAVE_DEBOUNCE_MS);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [editState, onSaved, profile]);
const saveNow = async () => {
const payload = buildSavePayload(editState);
if (!payload) {
setLocalError(testRunBlockers[0] ?? '请补全作品信息。');
return null;
}
setAutoSaveState('saving');
setLocalError(null);
const { item } = await updateMatch3DWork(profile.profileId, payload);
setAutoSaveState('saved');
onSaved?.(item);
return item;
};
const handleCoverImageChange = 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,
coverImageSrc: dataUrl,
}));
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '封面图读取失败。',
);
}
};
const handleStartTestRun = async () => {
if (!canStartTestRun || isStartingTestRun) {
setLocalError(testRunBlockers[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 handlePublish = async () => {
if (!canSubmit || isPublishing) {
setLocalError(blockers[0] ?? null);
return;
}
setIsPublishing(true);
try {
const savedProfile = await saveNow();
const { item } = await publishMatch3DWork(
savedProfile?.profileId ?? profile.profileId,
);
onPublished?.(item);
setLocalError(null);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '发布抓大鹅作品失败。',
);
} finally {
setIsPublishing(false);
}
};
const busy = isBusy || isPublishing || isStartingTestRun;
const displayError = error ?? localError;
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)]">
<Match3DResultHeader
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)]">
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="aspect-[4/3] overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-[radial-gradient(circle_at_35%_24%,rgba(190,242,100,0.28),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))]">
{editState.coverImageSrc ? (
<ResolvedAssetImage
src={editState.coverImageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center text-emerald-700">
<ImagePlus className="h-10 w-10" />
</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={handleCoverImageChange}
/>
</label>
<div className="mt-3 grid grid-cols-3 gap-2 text-center text-xs font-bold text-[var(--platform-text-base)]">
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{totalItemCount}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.clearCountText || '-'}
</div>
<div className="rounded-[1rem] bg-white/68 px-2 py-2">
{editState.difficultyText || '-'}
</div>
</div>
</section>
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
<div className="grid gap-3 sm:grid-cols-2">
<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.gameName}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, gameName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</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.tagsText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, tagsText: 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 sm:col-span-2">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={editState.summary}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, summary: event.target.value })
}
rows={3}
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</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.themeText}
disabled={busy}
onChange={(event) =>
setEditState({ ...editState, themeText: 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)]">
</span>
<input
value={editState.clearCountText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
clearCountText: 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)]">
</span>
<input
value={editState.difficultyText}
inputMode="numeric"
disabled={busy}
onChange={(event) =>
setEditState({
...editState,
difficultyText: 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>
</div>
{draft?.referenceImageSrc || profile.referenceImageSrc ? (
<div className="mt-4 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-2">
<ResolvedAssetImage
src={draft?.referenceImageSrc ?? profile.referenceImageSrc ?? ''}
alt=""
aria-hidden="true"
className="h-24 w-full rounded-[0.8rem] object-cover"
/>
</div>
) : null}
</section>
</div>
</div>
{displayError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{displayError}
</div>
) : 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">
<button
type="button"
onClick={handleStartTestRun}
disabled={!canStartTestRun || busy}
className={`platform-button platform-button--ghost min-h-11 justify-center gap-2 px-5 py-3 ${!canStartTestRun || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isStartingTestRun ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={handlePublish}
disabled={!canSubmit || busy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${!canSubmit || busy ? 'cursor-not-allowed opacity-55' : ''}`}
>
{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' ? '更新发布' : '发布'}
</button>
</div>
</div>
);
}
export default Match3DResultView;