Increase VectorEngine timeouts and add image UI

Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -0,0 +1,129 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import { CustomWorldGenerationView } from './CustomWorldGenerationView';
function createProgress(
overrides: Partial<CustomWorldGenerationProgress> = {},
): CustomWorldGenerationProgress {
return {
phaseId: 'draft_foundation',
phaseLabel: '整理草稿',
phaseDetail: '正在整理当前生成步骤。',
batchLabel: '第 2 批',
overallProgress: 42,
completedWeight: 21,
totalWeight: 50,
elapsedMs: 125_000,
estimatedRemainingMs: 75_000,
activeStepIndex: 1,
steps: [
{
id: 'step-1',
label: '收集设定',
detail: '整理初始输入。',
completed: 1,
total: 1,
status: 'completed',
},
{
id: 'step-2',
label: '编译草稿',
detail: '生成首版结构。',
completed: 2,
total: 4,
status: 'active',
},
{
id: 'step-3',
label: '写回结果',
detail: '同步结果页。',
completed: 0,
total: 4,
status: 'pending',
},
],
...overrides,
};
}
describe('CustomWorldGenerationView', () => {
test.each(['拼图草稿生成进度', '抓大鹅草稿生成进度'])(
'hides batch module and keeps wait/timer in one row for %s',
(progressTitle) => {
render(
<CustomWorldGenerationView
settingText="竖屏生成题材"
progress={createProgress()}
isGenerating
error={null}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
settingDescription={null}
settingActionLabel={null}
progressTitle={progressTitle}
/>,
);
expect(screen.queryByText('当前批次')).toBeNull();
expect(screen.getByText('预计等待')).toBeTruthy();
expect(screen.getByText('计时')).toBeTruthy();
const statsNode = screen
.getByText('预计等待')
.closest('.custom-world-generation-stats');
expect(statsNode?.className).toContain(
'custom-world-generation-stats--two-column',
);
expect(statsNode?.getAttribute('style')).toContain(
'grid-template-columns: repeat(2, minmax(0, 1fr))',
);
const stepNodes = [
screen.getByText('收集设定'),
screen.getByText('编译草稿'),
screen.getByText('写回结果'),
].map((node) => node.closest('.custom-world-generation-step'));
expect(stepNodes.every(Boolean)).toBe(true);
expect(stepNodes[0]?.getAttribute('style')).toContain(
'--generation-step-delay: 0ms',
);
expect(stepNodes[1]?.getAttribute('style')).toContain(
'--generation-step-delay: 90ms',
);
expect(stepNodes[2]?.getAttribute('style')).toContain(
'--generation-step-delay: 180ms',
);
},
);
test('keeps batch module for other generation pages', () => {
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
progress={createProgress()}
isGenerating
error={null}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
settingDescription={null}
settingActionLabel={null}
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
expect(screen.getByText('当前批次')).toBeTruthy();
expect(
screen
.getByText('预计等待')
.closest('.custom-world-generation-stats')
?.className,
).not.toContain('custom-world-generation-stats--two-column');
});
});

View File

@@ -1,4 +1,6 @@
import { motion } from 'motion/react';
import type { CSSProperties } from 'react';
import { useEffect, useState } from 'react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
@@ -24,6 +26,7 @@ interface CustomWorldGenerationViewProps {
pausedBadgeLabel?: string;
idleBadgeLabel?: string;
structuredEmptyText?: string;
hideBatchModule?: boolean;
}
function formatDuration(ms: number) {
@@ -86,6 +89,49 @@ function buildFallbackRenderKey(
return normalizedValue ? normalizedValue : fallback;
}
function useIsMobileGenerationLayout() {
const [isMobile, setIsMobile] = useState(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia('(max-width: 639px)').matches;
});
useEffect(() => {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 639px)');
const syncMobileLayout = () => {
setIsMobile(mediaQuery.matches);
};
syncMobileLayout();
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncMobileLayout);
return () => {
mediaQuery.removeEventListener('change', syncMobileLayout);
};
}
mediaQuery.addListener(syncMobileLayout);
return () => {
mediaQuery.removeListener(syncMobileLayout);
};
}, []);
return isMobile;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
@@ -107,7 +153,9 @@ export function CustomWorldGenerationView({
pausedBadgeLabel = '生成已暂停',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
}: CustomWorldGenerationViewProps) {
const isMobileGenerationLayout = useIsMobileGenerationLayout();
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
@@ -116,6 +164,10 @@ export function CustomWorldGenerationView({
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const shouldHideBatchModule =
hideBatchModule ||
progressTitle === '拼图草稿生成进度' ||
progressTitle === '抓大鹅草稿生成进度';
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -179,28 +231,41 @@ export function CustomWorldGenerationView({
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3 xl:gap-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
<div
className={`custom-world-generation-stats mt-4 grid gap-2 xl:gap-3 ${
shouldHideBatchModule
? 'custom-world-generation-stats--two-column grid-cols-2'
: 'sm:grid-cols-3'
}`}
style={
shouldHideBatchModule
? { gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }
: undefined
}
>
{shouldHideBatchModule ? null : (
<div className="platform-subpanel min-w-0 rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="mt-1 text-sm font-semibold text-white">
{progress?.batchLabel ?? '准备中'}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-3">
)}
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{estimatedWaitText}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="platform-subpanel min-w-0 rounded-2xl px-3 py-3 sm:px-4">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
</div>
<div className="mt-1 text-sm font-semibold text-white">
<div className="mt-1 break-keep text-xs font-semibold text-white sm:text-sm">
{elapsedText}
</div>
</div>
@@ -211,7 +276,7 @@ export function CustomWorldGenerationView({
const stepProgress = getStepProgressPercentage(step);
return (
<div
<motion.div
key={buildFallbackRenderKey(
step.id,
`progress-step-${index}`,
@@ -222,7 +287,23 @@ export function CustomWorldGenerationView({
: step.status === 'active'
? 'border-sky-300/22 bg-sky-500/10'
: 'platform-subpanel'
}`}
} custom-world-generation-step`}
initial={
isMobileGenerationLayout
? { opacity: 0, x: '-110vw' }
: false
}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.34,
ease: 'easeOut',
delay: isMobileGenerationLayout ? index * 0.09 : 0,
}}
style={
{
'--generation-step-delay': `${index * 90}ms`,
} as CSSProperties
}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-semibold text-white">
@@ -248,7 +329,7 @@ export function CustomWorldGenerationView({
<div className="mt-2 text-xs leading-6 text-zinc-400">
{step.detail}
</div>
</div>
</motion.div>
);
})}
</div>

View File

@@ -12,7 +12,7 @@ describe('BarkBattleConfigEditor', () => {
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
expect(screen.getByText('轻配置作品')).toBeTruthy();
expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true);
@@ -47,4 +47,22 @@ describe('BarkBattleConfigEditor', () => {
expect(onPublish).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
});
it('can render as an embedded creation form without a local page header', () => {
const onPublish = vi.fn();
render(
<BarkBattleConfigEditor
error="发布失败"
isBusy={false}
onPublish={onPublish}
showBackButton={false}
title={null}
/>,
);
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy();
});
});

View File

@@ -1,3 +1,4 @@
import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
@@ -6,8 +7,11 @@ import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
error?: string | null;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
showBackButton?: boolean;
title?: string | null;
};
const THEME_OPTIONS = [
@@ -30,8 +34,11 @@ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: stri
export function BarkBattleConfigEditor({
isBusy = false,
error: externalError = null,
onPublish,
onBack,
showBackButton = true,
title: headingTitle = '汪汪声浪大作战',
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
@@ -40,7 +47,7 @@ export function BarkBattleConfigEditor({
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [error, setError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
@@ -65,96 +72,212 @@ export function BarkBattleConfigEditor({
const handlePublish = () => {
if (!payload.title) {
setError('请先填写作品标题');
setLocalError('请先填写作品标题');
return;
}
setError(null);
setLocalError(null);
void onPublish(payload);
};
const visibleError = localError ?? externalError;
return (
<section className="min-h-screen bg-slate-950 px-4 py-6 text-slate-50 sm:px-6" aria-label="Bark Battle 轻配置编辑器">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-3xl border border-cyan-300/20 bg-slate-900/90 p-5 shadow-2xl shadow-cyan-950/40">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<p className="mb-2 inline-flex rounded-full bg-cyan-300/10 px-3 py-1 text-xs font-bold text-cyan-100"></p>
<h1 className="text-2xl font-black tracking-tight sm:text-3xl"></h1>
<p className="mt-2 text-sm text-slate-300"></p>
<section
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{headingTitle}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
</span>
</div>
{onBack ? (
<button type="button" onClick={onBack} className="rounded-full border border-slate-600 px-3 py-2 text-sm text-slate-200">
</button>
</div>
) : null}
<div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={title}
disabled={isBusy}
onChange={(event) => setTitle(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={40}
aria-label="作品标题"
/>
</label>
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={description}
disabled={isBusy}
onChange={(event) => setDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={160}
placeholder=""
aria-label="简介"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
value={themePreset}
disabled={isBusy}
onChange={(event) => setThemePreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="主题背景"
>
{THEME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
value={difficultyPreset}
disabled={isBusy}
onChange={(event) =>
setDifficultyPreset(
event.target.value as BarkBattleDifficultyPreset,
)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="难度预设"
>
{DIFFICULTY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setPlayerDogSkinPreset(event.target.value)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) =>
setOpponentDogSkinPreset(event.target.value)
}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手狗狗"
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]">
<span className="inline-flex items-center gap-2">
<Trophy className="h-4 w-4 text-amber-500" />
</span>
<input
aria-label="开启排行榜"
type="checkbox"
checked={leaderboardEnabled}
disabled={isBusy}
onChange={(event) =>
setLeaderboardEnabled(event.target.checked)
}
className="h-5 w-5 accent-[#ff4f6a]"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError}
</div>
) : null}
</div>
<div className="grid gap-4">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={40}
/>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
className="min-h-[88px] rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={160}
placeholder="一句话告诉玩家这场声浪对决的氛围"
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={themePreset} onChange={(event) => setThemePreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{THEME_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={difficultyPreset} onChange={(event) => setDifficultyPreset(event.target.value as BarkBattleDifficultyPreset)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DIFFICULTY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={playerDogSkinPreset} onChange={(event) => setPlayerDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={opponentDogSkinPreset} onChange={(event) => setOpponentDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-sm font-semibold text-slate-100">
<span>
<span className="block text-xs font-normal text-slate-400"></span>
</span>
<input aria-label="开启排行榜" type="checkbox" checked={leaderboardEnabled} onChange={(event) => setLeaderboardEnabled(event.target.checked)} className="h-5 w-5" />
</label>
{error ? <p className="rounded-2xl bg-rose-500/15 px-4 py-3 text-sm font-semibold text-rose-100">{error}</p> : null}
<button type="button" disabled={isBusy} onClick={handlePublish} className="rounded-full bg-cyan-200 px-5 py-3 text-sm font-black text-slate-950 disabled:opacity-50">
{isBusy ? '发布中…' : '发布并试玩'}
</button>
</div>
<BarkBattlePreviewCard config={payload} />
</div>
</div>
<BarkBattlePreviewCard config={payload} />
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={isBusy}
onClick={handlePublish}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<WandSparkles className="h-4 w-4" />
)}
<span>{isBusy ? '发布中' : '发布并试玩'}</span>
</span>
</button>
</div>
</section>
);

View File

@@ -24,30 +24,49 @@ const DIFFICULTY_LABELS = {
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return (
<aside className="rounded-3xl border border-cyan-300/20 bg-gradient-to-br from-slate-900 via-slate-950 to-cyan-950 p-5 text-slate-50 shadow-2xl shadow-cyan-950/40" aria-label="作品预览卡片">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.25em] text-cyan-200">Preview</p>
<div className="rounded-3xl border border-white/10 bg-white/10 p-5">
<div className="mb-5 flex min-h-40 items-center justify-center rounded-3xl bg-cyan-200/10 text-6xl" aria-hidden="true">
🐶 VS 🐺
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
aria-label="作品预览卡片"
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div
className="mb-4 flex min-h-[8.5rem] items-center justify-center rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] text-5xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
aria-hidden="true"
>
<span> VS </span>
</div>
<h2 className="text-xl font-black">{config.title || '未命名声浪竞技场'}</h2>
<p className="mt-2 min-h-[42px] text-sm text-slate-300">{config.description || '30 秒声浪拔河,喊出你的能量优势。'}</p>
<dl className="mt-5 grid gap-3 text-sm">
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{THEME_LABELS[config.themePreset] ?? config.themePreset}</dd>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
{config.title || '未命名声浪竞技场'}
</h2>
<p className="mt-2 min-h-[2.625rem] text-sm font-semibold leading-6 text-[var(--platform-text-muted)]">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p>
<dl className="mt-4 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{THEME_LABELS[config.themePreset] ?? config.themePreset}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset} vs {DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}</dd>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset}
{' vs '}
{DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DIFFICULTY_LABELS[config.difficultyPreset]}</dd>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{config.leaderboardEnabled ? '开启' : '关闭'}</dd>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.leaderboardEnabled ? '开启' : '关闭'}
</dd>
</div>
</dl>
</div>

View File

@@ -0,0 +1,145 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
test('creative image input panel handles reference uploads and preview', () => {
const onPromptReferenceFilesSelect = vi.fn();
const onPromptReferenceRemove = vi.fn();
const onSubmit = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[
{
id: 'ref-1',
label: '参考图 1',
imageSrc: 'data:image/png;base64,ref-1',
},
]}
imageModelPicker={<div />}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onPromptReferenceRemove={onPromptReferenceRemove}
onSubmit={onSubmit}
/>,
);
const promptReferenceInput = screen.getByLabelText('上传参考图', {
selector: 'input',
});
expect((promptReferenceInput as HTMLInputElement).multiple).toBe(true);
fireEvent.change(promptReferenceInput, {
target: {
files: [
new File(['a'], 'ref-1.png', { type: 'image/png' }),
new File(['b'], 'ref-2.png', { type: 'image/png' }),
],
},
});
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith(
expect.arrayContaining([
expect.any(File),
expect.any(File),
]),
);
fireEvent.click(
screen.getByRole('button', { name: '预览参考图 参考图 1' }),
);
expect(
screen.getByRole('dialog', { name: '参考图 1' }),
).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('ref-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
fireEvent.click(screen.getByRole('button', { name: '移除参考图 参考图 1' }));
expect(onPromptReferenceRemove).toHaveBeenCalledWith('ref-1');
fireEvent.click(screen.getByRole('button', { name: '生成' }));
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel confirms before removing uploaded image', () => {
const onMainImageRemove = vi.fn();
render(
<CreativeImageInputPanel
uploadedImageSrc="data:image/png;base64,main"
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面AI重绘要求提示词"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={<div />}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={onMainImageRemove}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' });
expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '移除' }));
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,463 @@
import {
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
export type CreativeImageInputReferenceImage = {
id: string;
label: string;
imageSrc: string;
};
export type CreativeImageInputPanelLabels = {
imageField: string;
uploadImage: string;
replaceImage: string;
emptyImageHint: string;
removeImage: string;
removeImageConfirmTitle: string;
removeImageConfirmBody: string;
promptReferenceUpload: string;
promptReferencePreviewAlt: string;
closePromptReferencePreview: string;
history?: string;
};
export type CreativeImageInputPanelProps = {
className?: string;
disabled?: boolean;
isSubmitting?: boolean;
uploadedImageSrc: string;
uploadedImageAlt: string;
mainImageInputId: string;
mainImageAccept?: string;
promptTextareaId: string;
prompt: string;
promptLabel: string;
promptRows?: number;
aiRedraw: boolean;
promptReferenceImages: CreativeImageInputReferenceImage[];
promptReferenceLimit?: number;
imageModelPicker?: ReactNode;
error?: string | null;
inputError?: string | null;
submitLabel: string;
submitCostLabel?: string | null;
submitDisabled: boolean;
labels: CreativeImageInputPanelLabels;
onMainImageFileSelect: (file: File) => void;
onMainImageRemove: () => void;
onAiRedrawChange: (enabled: boolean) => void;
onPromptChange: (value: string) => void;
onPromptReferenceFilesSelect?: (files: File[]) => void;
onPromptReferenceRemove?: (referenceId: string) => void;
onHistoryClick?: () => void;
onSubmit: () => void;
};
const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
export function CreativeImageInputPanel({
className = '',
disabled = false,
isSubmitting = false,
uploadedImageSrc,
uploadedImageAlt,
mainImageInputId,
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
promptTextareaId,
prompt,
promptLabel,
promptRows = 2,
aiRedraw,
promptReferenceImages,
promptReferenceLimit = DEFAULT_PROMPT_REFERENCE_LIMIT,
imageModelPicker = null,
error = null,
inputError = null,
submitLabel,
submitCostLabel = null,
submitDisabled,
labels,
onMainImageFileSelect,
onMainImageRemove,
onAiRedrawChange,
onPromptChange,
onPromptReferenceFilesSelect,
onPromptReferenceRemove,
onHistoryClick,
onSubmit,
}: CreativeImageInputPanelProps) {
const [previewReferenceImage, setPreviewReferenceImage] =
useState<CreativeImageInputReferenceImage | null>(null);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = !uploadedImageSrc || aiRedraw;
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
useEffect(() => {
if (uploadedImageSrc) {
setPreviewReferenceImage(null);
}
}, [uploadedImageSrc]);
useEffect(() => {
if (
previewReferenceImage &&
!promptReferenceImages.some(
(reference) => reference.id === previewReferenceImage.id,
)
) {
setPreviewReferenceImage(null);
}
}, [previewReferenceImage, promptReferenceImages]);
return (
<div
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
>
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
<div
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
showPrompt
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'flex flex-col lg:grid lg:grid-cols-1'
}`}
>
<div
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
disabled ? 'opacity-55' : ''
}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
{labels.imageField}
</div>
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
<input
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
}}
className="sr-only"
/>
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
>
<span className="sr-only">
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
</span>
</label>
{uploadedImageSrc ? (
<ResolvedAssetImage
src={uploadedImageSrc}
alt={uploadedImageAlt}
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{onHistoryClick ? (
<button
type="button"
disabled={disabled}
onClick={onHistoryClick}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${
disabled ? 'cursor-not-allowed opacity-55' : ''
}`}
aria-label={labels.history ?? '选择历史图片'}
title={labels.history ?? '选择历史图片'}
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
) : null}
{uploadedImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={aiRedraw}
disabled={disabled}
onChange={(event) => onAiRedrawChange(event.target.checked)}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null}
{uploadedImageSrc ? (
<button
type="button"
disabled={disabled}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.removeImage}
title={labels.removeImage}
>
<Trash2 className="h-4 w-4" />
</button>
) : (
<label
htmlFor={mainImageInputId}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${
disabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
>
{labels.emptyImageHint}
</label>
)}
</div>
</div>
</div>
{showPrompt ? (
<div className="block shrink-0 lg:min-h-0">
<label
htmlFor={promptTextareaId}
className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"
>
{promptLabel}
</label>
<div className="relative">
<textarea
id={promptTextareaId}
value={prompt}
disabled={disabled}
rows={promptRows}
placeholder=""
onChange={(event) => onPromptChange(event.target.value)}
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={promptLabel}
/>
{imageModelPicker}
{!uploadedImageSrc && onPromptReferenceFilesSelect ? (
<label
className={`absolute bottom-3 right-3 z-10 inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] hover:text-[#ff4056] ${
promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
aria-label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
>
<ImagePlus className="h-4 w-4" />
<input
type="file"
accept={mainImageAccept}
multiple
aria-label={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
if (files.length > 0) {
onPromptReferenceFilesSelect(files);
}
}}
className="sr-only"
/>
</label>
) : null}
</div>
{!uploadedImageSrc && promptReferenceImages.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{promptReferenceImages.map((reference) => (
<div
key={reference.id}
className="relative h-12 w-12 overflow-hidden rounded-[0.75rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-sm"
>
<button
type="button"
disabled={disabled}
onClick={() => setPreviewReferenceImage(reference)}
className="block h-full w-full"
aria-label={`预览参考图 ${reference.label}`}
title={reference.label}
>
<ResolvedAssetImage
src={reference.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
</button>
{onPromptReferenceRemove ? (
<button
type="button"
disabled={disabled}
onClick={() => onPromptReferenceRemove(reference.id)}
className="absolute right-0.5 top-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/94 text-[var(--platform-text-strong)] shadow-sm transition hover:text-[#ff4056] disabled:opacity-55"
aria-label={`移除参考图 ${reference.label}`}
title="移除参考图"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-2 shrink-0 space-y-3">
{inputError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{inputError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={disabled || submitDisabled}
onClick={onSubmit}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${
submitDisabled ? 'cursor-not-allowed opacity-55' : ''
}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>{submitLabel}</span>
{submitCostLabel ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{submitCostLabel}
</span>
) : null}
</span>
</button>
</div>
{previewReferenceImage ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
onClick={() => setPreviewReferenceImage(null)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-reference-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-2xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-reference-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{previewReferenceImage.label}
</div>
<button
type="button"
aria-label={labels.closePromptReferencePreview}
onClick={() => setPreviewReferenceImage(null)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={previewReferenceImage.imageSrc}
alt={labels.promptReferencePreviewAlt}
className="h-full max-h-[72vh] w-full object-contain"
/>
</div>
</div>
</div>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-remove-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="creative-image-remove-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
{labels.removeImageConfirmTitle}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{labels.removeImageConfirmBody}
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={() => {
onMainImageRemove();
setIsRemoveImageConfirmOpen(false);
}}
className="platform-button platform-button--primary justify-center"
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
export default CreativeImageInputPanel;

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -83,7 +83,9 @@ const testEntryConfig = {
],
} satisfies CreationEntryConfig;
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
const testCreationTypes = derivePlatformCreationTypes(
testEntryConfig.creationTypes,
);
const originalClipboard = navigator.clipboard;
@@ -315,7 +317,9 @@ test('creation hub hides square hole works when the creation type is hidden', ()
);
expect(screen.queryByText('隐藏方洞挑战')).toBeNull();
expect(screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。')).toBeNull();
expect(
screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。'),
).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
@@ -494,10 +498,43 @@ test('creation hub hides persisted draft delete action behind swipe underlay', (
/>,
);
expect(container.querySelector('.creation-work-card__swipe-underlay')).toBeTruthy();
expect(
container.querySelector('.creation-work-card__swipe-underlay'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from left swipe', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
fireEvent.touchMove(card, {
touches: [{ clientX: 92, clientY: 22 }],
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
});
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
@@ -519,6 +556,7 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub shows delete action for baby object match drafts', async () => {
@@ -548,11 +586,13 @@ test('creation hub shows delete action for baby object match drafts', async () =
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(babyObjectMatchDraftItem);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(
babyObjectMatchDraftItem,
);
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -705,3 +745,35 @@ test('creation hub published swipe share button copies share text without openin
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('creation hub left swipe draft reveals delete without opening card', () => {
const onDeletePublished = vi.fn();
const onOpenDraft = vi.fn();
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={onOpenDraft}
onEnterPublished={() => {}}
onDeletePublished={onDeletePublished}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
fireEvent.touchMove(card, {
touches: [{ clientX: 88, clientY: 22 }],
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(onOpenDraft).not.toHaveBeenCalled();
});

View File

@@ -1,8 +1,15 @@
import { Share2, Trash2 } from 'lucide-react';
import {
BadgeCheck,
Clock3,
Loader2,
Share2,
Trash2,
} from 'lucide-react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
useEffect,
useMemo,
useRef,
@@ -239,7 +246,8 @@ export function CustomWorldWorkCard({
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath);
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath);
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
@@ -252,54 +260,28 @@ export function CustomWorldWorkCard({
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const surfaceOffset = isSwipeDragging
const coverFadeStyle = {
WebkitMaskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
maskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
} as CSSProperties;
const currentSwipeOffset = isSwipeDragging
? swipeOffset
: isSwipeActionRevealed
? -swipeRevealWidth
: 0;
const swipeActionOpacity =
swipeRevealWidth > 0 ? Math.min(1, Math.abs(surfaceOffset) / swipeRevealWidth) : 0;
const swipeSurfaceStyle = {
'--creation-work-card-swipe-offset': `${surfaceOffset}px`,
} as CSSProperties;
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${swipeActionOpacity}`,
} as CSSProperties;
const sideCoverStyle = {
const cardSurfaceStyle = {
'--creation-work-card-swipe-offset': `${currentSwipeOffset}px`,
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
} as CSSProperties;
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${
swipeRevealWidth > 0
? Math.min(1, Math.abs(currentSwipeOffset) / swipeRevealWidth)
: 0
}`,
} as CSSProperties;
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
@@ -324,57 +306,42 @@ export function CustomWorldWorkCard({
}, 1400);
});
};
useEffect(
() => () => {
useEffect(() => {
return () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
},
[],
);
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
}, []);
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const updateSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
const deltaX = event.clientX - gesture.startX;
const deltaY = event.clientY - gesture.startY;
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const updateSwipeOffset = (
gesture: NonNullable<typeof swipeGestureRef.current>,
clientX: number,
clientY: number,
preventDefault: () => void,
) => {
const deltaX = clientX - gesture.startX;
const deltaY = clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
@@ -393,7 +360,7 @@ export function CustomWorldWorkCard({
}
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
event.preventDefault();
preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
@@ -403,19 +370,9 @@ export function CustomWorldWorkCard({
setSwipeOffset(nextOffset);
};
const endSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
const finishSwipeGesture = (wasDragging: boolean) => {
setIsSwipeDragging(false);
if (!gesture.isDragging) {
if (!wasDragging) {
return;
}
@@ -431,9 +388,74 @@ export function CustomWorldWorkCard({
scheduleOpenSuppressReset();
};
const cancelSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
};
const updateSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
);
};
const endSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
@@ -448,6 +470,69 @@ export function CustomWorldWorkCard({
}
};
const beginTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
if (swipeRevealWidth <= 0) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
swipeGestureRef.current = {
pointerId: -1,
startX: touch.clientX,
startY: touch.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
};
const updateTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
const touch = event.touches[0];
if (!gesture || gesture.pointerId !== -1 || !touch) {
return;
}
updateSwipeOffset(
gesture,
touch.clientX,
touch.clientY,
() => event.preventDefault(),
);
};
const endTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
@@ -458,7 +543,12 @@ export function CustomWorldWorkCard({
};
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
if (
(event.key === 'ArrowLeft' ||
event.key === 'ContextMenu' ||
(event.shiftKey && event.key === 'F10')) &&
swipeRevealWidth > 0
) {
event.preventDefault();
revealSwipeActions();
return;
@@ -578,38 +668,59 @@ export function CustomWorldWorkCard({
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
style={swipeSurfaceStyle}
onTouchStart={beginTouchSwipeGesture}
onTouchMove={updateTouchSwipeGesture}
onTouchEnd={endTouchSwipeGesture}
onTouchCancel={cancelTouchSwipeGesture}
onContextMenu={(event) => {
if (swipeRevealWidth <= 0) {
return;
}
event.preventDefault();
revealSwipeActions();
}}
style={cardSurfaceStyle}
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
<span
className={`creation-work-card__status-pill creation-work-card__status-pill--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'}
</span>
<div className="creation-work-card__title-lockup">
<span
aria-label={
item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'
}
className={`creation-work-card__state-mark creation-work-card__state-mark--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating ? (
<Loader2 aria-hidden="true" className="h-3.5 w-3.5" />
) : item.status === 'published' ? (
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</span>
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
</div>
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges
.slice(1)
.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
{item.badges.slice(1).map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
@@ -698,18 +809,20 @@ export function CustomWorldWorkCard({
<div
className="creation-work-card__side-cover"
style={sideCoverStyle}
style={coverFadeStyle}
aria-hidden="true"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
<div className="creation-work-card__side-cover-inner">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
</div>
{item.hasUnreadUpdate ? (
<span
@@ -719,7 +832,10 @@ export function CustomWorldWorkCard({
) : null}
{item.isGenerating ? (
<div className="creation-work-card__generating-mask" aria-hidden="true">
<div
className="creation-work-card__generating-mask"
aria-hidden="true"
>
<span className="creation-work-card__spinner" />
<span>...</span>
</div>

View File

@@ -393,7 +393,7 @@ test('buildCreationWorkShelfItems uses generated object keys as cover sources',
'generated-puzzle-assets/session/profile/level-cover.png',
);
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/background/image.png',
'generated-match3d-assets/session/profile/background/container.png',
);
});
@@ -444,6 +444,182 @@ test('buildCreationWorkShelfItems falls back to match3d item object key without
);
});
test('buildCreationWorkShelfItems ignores puzzle theme reference cover and uses first level image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:theme-reference-cover',
profileId: 'puzzle-profile-theme-reference-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '主题兜底拼图',
summary: '摘要里的封面是玩法参考图时,用第一关画面兜底。',
themeTags: [],
coverImageSrc: '/creation-type-references/puzzle.webp',
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '第一关画面。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle-first-level-candidate.png',
assetId: 'asset-1',
prompt: '第一关画面',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle-first-level-cover.png',
coverAssetId: 'asset-1',
generationStatus: 'ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'/puzzle-first-level-cover.png',
);
});
test('buildCreationWorkShelfItems ignores match3d theme reference cover and uses container image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:theme-reference-cover',
profileId: 'match3d-profile-theme-reference-cover',
ownerUserId: 'user-1',
gameName: '主题兜底抓鹅',
themeText: '糖果厨房',
summary: '摘要里的封面是玩法参考图时用UI背景图兜底。',
tags: [],
coverImageSrc: '/creation-type-references/match3d.webp',
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedBackgroundAsset: {
prompt: '糖果厨房竖屏UI背景',
imageSrc: '/match3d-ui-background.png',
containerImageSrc: '/match3d-container.png',
status: 'image_ready',
},
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-container.png',
);
});
test('buildCreationWorkShelfItems uses match3d container asset before background and item image', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:item-background-asset-cover',
profileId: 'match3d-profile-item-background-asset-cover',
ownerUserId: 'user-1',
gameName: '背景资产抓鹅',
themeText: '糖果厨房',
summary: '顶层背景缺失时从素材携带的UI背景兜底。',
tags: [],
coverImageSrc: '/creation-type-references/match3d.webp',
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
backgroundAsset: {
prompt: '糖果厨房竖屏UI背景',
imageObjectKey:
'generated-match3d-assets/session/profile/background/image.png',
containerImageObjectKey:
'generated-match3d-assets/session/profile/ui-container/container.png',
status: 'image_ready',
},
status: 'image_ready',
},
],
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'generated-match3d-assets/session/profile/ui-container/container.png',
);
});
test('buildCreationWorkShelfItems uses match3d transparent container reference as last fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d:container-reference-fallback',
profileId: 'match3d-profile-container-reference-fallback',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
backgroundPrompt: '',
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [],
clearCount: 3,
difficulty: 2,
publicationStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-background-references/pot-fused-reference.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

View File

@@ -17,6 +17,9 @@ import {
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
'/match3d-background-references/pot-fused-reference.png';
export type CreationWorkShelfKind =
| 'rpg'
| 'big-fish'
@@ -620,15 +623,33 @@ function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
const normalizedValue = normalizeCoverImageSrc(value);
if (!normalizedValue) {
return false;
}
// 中文注释:玩法参考图只做草稿页兜底,不应覆盖作品已经生成出来的真实关卡图或运行态背景图。
return /^\/?creation-type-references\/[^/?#]+(?:[?#].*)?$/u.test(
normalizedValue,
);
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
if (
directCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
) {
return directCoverImageSrc;
}
for (const level of item.levels ?? []) {
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
if (levelCoverImageSrc) {
if (
levelCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
) {
return levelCoverImageSrc;
}
@@ -638,14 +659,17 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
)
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (candidateImageSrc) {
if (
candidateImageSrc &&
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
) {
return candidateImageSrc;
}
}
@@ -655,22 +679,46 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
if (
directCoverImageSrc &&
!isCreationTypeReferenceCoverImageSrc(directCoverImageSrc)
) {
return directCoverImageSrc;
}
const topLevelContainerImageSrc =
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
if (topLevelContainerImageSrc) {
return topLevelContainerImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const assetContainerImageSrc =
normalizeCoverImageSrc(asset.backgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(asset.backgroundAsset?.containerImageObjectKey);
if (assetContainerImageSrc) {
return assetContainerImageSrc;
}
}
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.backgroundImageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageObjectKey);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const assetBackgroundImageSrc =
normalizeCoverImageSrc(asset.backgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(asset.backgroundAsset?.imageObjectKey);
if (assetBackgroundImageSrc) {
return assetBackgroundImageSrc;
}
const imageView = asset.imageViews?.find(
(view) =>
normalizeCoverImageSrc(view.imageSrc) ||
@@ -682,12 +730,16 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const itemImageSrc =
normalizeCoverImageSrc(asset.imageSrc) ||
normalizeCoverImageSrc(asset.imageObjectKey);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
const preferredImageSrc = imageViewSrc || itemImageSrc;
if (
preferredImageSrc &&
!isCreationTypeReferenceCoverImageSrc(preferredImageSrc)
) {
return preferredImageSrc;
}
}
return null;
return MATCH3D_CONTAINER_REFERENCE_COVER_SRC;
}
function resolveSquareHoleWorkCoverImageSrc(item: SquareHoleWorkSummary) {

View File

@@ -1,6 +1,12 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { Match3DWorkProfile } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -147,6 +153,7 @@ describe('Match3DResultView', () => {
expect(screen.getByText('作品标签')).toBeTruthy();
expect(screen.getByText('水果')).toBeTruthy();
expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.queryByRole('button', { name: '封面图' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
@@ -188,7 +195,7 @@ describe('Match3DResultView', () => {
});
});
test('封面图独立面板支持引用物品素材作为多参考图生成', async () => {
test('发布面板支持引用物品素材作为多参考图生成封面', async () => {
const profile = createProfile({
generatedItemAssets: [
{
@@ -232,13 +239,19 @@ describe('Match3DResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
expect(screen.getByRole('dialog', { name: '封面图' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '引用草莓' }));
fireEvent.change(screen.getByLabelText('封面描述'), {
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '引用草莓' }),
);
fireEvent.change(within(publishDialog).getByLabelText('封面描述'), {
target: { value: '草莓抓大鹅封面图' },
});
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
expect(
@@ -251,7 +264,74 @@ describe('Match3DResultView', () => {
uploadedImageSrc: null,
});
expect(onSaved).toHaveBeenCalledWith(nextProfile);
expect(screen.queryByRole('dialog', { name: '封面图' })).toBeNull();
expect(
screen.getByRole('dialog', { name: '发布抓大鹅作品' }),
).toBeTruthy();
});
});
test('生成封面图只更新封面字段,不用旧回包覆盖当前物品素材和配置', async () => {
const generatedItemAssets = [createReadyGeneratedItemAsset(1)];
const profile = createProfile({
clearCount: 12,
difficulty: 4,
generatedItemAssets,
});
const staleResponseProfile = createProfile({
...profile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
clearCount: 8,
difficulty: 2,
generatedItemAssets: [],
});
const onSaved = vi.fn();
vi.mocked(match3dWorksService.generateMatch3DCoverImage).mockResolvedValue({
item: staleResponseProfile,
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
coverImageObjectKey:
'generated-match3d-assets/session/profile/cover/task/cover.png',
prompt: '水果封面图',
});
render(
<Match3DResultView
profile={profile}
onBack={() => {}}
onSaved={onSaved}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.change(within(publishDialog).getByLabelText('封面描述'), {
target: { value: '水果封面图' },
});
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
expect(onSaved).toHaveBeenCalledWith(
expect.objectContaining({
coverImageSrc:
'/generated-match3d-assets/session/profile/cover/task/cover.png',
clearCount: 12,
difficulty: 4,
generatedItemAssets: expect.arrayContaining([
expect.objectContaining({
itemId: 'match3d-item-1',
imageViews: expect.arrayContaining([
expect.objectContaining({ viewId: 'view-01' }),
]),
}),
]),
}),
);
});
});
@@ -280,9 +360,14 @@ describe('Match3DResultView', () => {
/>,
);
fireEvent.click(screen.getByRole('button', { name: '封面图' }));
fireEvent.click(screen.getByRole('button', { name: '发布' }));
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
fireEvent.change(
screen.getByLabelText('上传封面图', { selector: 'input' }),
within(publishDialog).getByLabelText('上传封面图', {
selector: 'input',
}),
{
target: {
files: [new File(['x'], 'cover.png', { type: 'image/png' })],
@@ -291,16 +376,22 @@ describe('Match3DResultView', () => {
);
await waitFor(() => {
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
expect(screen.getByRole('button', { name: '移除封面图' })).toBeTruthy();
expect(screen.getByLabelText('AI重绘要求')).toBeTruthy();
expect(
within(publishDialog).getByRole('switch', { name: 'AI重绘' }),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '移除封面图' }),
).toBeTruthy();
expect(within(publishDialog).getByLabelText('AI重绘要求')).toBeTruthy();
});
expect(screen.queryByText('参考图')).toBeNull();
expect(within(publishDialog).queryByText('参考图')).toBeNull();
fireEvent.change(screen.getByLabelText('AI重绘要求'), {
fireEvent.change(within(publishDialog).getByLabelText('AI重绘要求'), {
target: { value: '保留构图,改成节日果园' },
});
fireEvent.click(screen.getByRole('button', { name: '生成封面图' }));
fireEvent.click(
within(publishDialog).getByRole('button', { name: '生成封面图' }),
);
await waitFor(() => {
expect(
@@ -418,9 +509,23 @@ describe('Match3DResultView', () => {
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', true);
expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
expect(within(publishDialog).getByText('封面图不能为空。')).toBeTruthy();
expect(
within(publishDialog).getByText('标签数量需要在 3 到 6 个之间。'),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
fireEvent.click(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
);
expect(within(publishDialog).getByText('封面图不能为空。')).toBeTruthy();
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
});
@@ -449,9 +554,21 @@ describe('Match3DResultView', () => {
);
const publishButton = screen.getByRole('button', { name: '发布' });
expect(publishButton).toHaveProperty('disabled', true);
expect(publishButton).toHaveProperty('disabled', false);
fireEvent.click(publishButton);
const publishDialog = screen.getByRole('dialog', {
name: '发布抓大鹅作品',
});
expect(
within(publishDialog).getByText(
'当前难度需要 3 种物品,已生成 2 种,请先在素材配置中补齐。',
),
).toBeTruthy();
expect(
within(publishDialog).getByRole('button', { name: '发布到广场' }),
).toHaveProperty('disabled', true);
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
fireEvent.click(within(publishDialog).getByRole('button', { name: '取消' }));
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
expect(screen.getByText('已生成物品种类')).toBeTruthy();
expect(screen.getAllByText('2 种').length).toBeGreaterThan(0);
@@ -505,6 +622,11 @@ describe('Match3DResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: '发布' }));
fireEvent.click(
within(
screen.getByRole('dialog', { name: '发布抓大鹅作品' }),
).getByRole('button', { name: '发布到广场' }),
);
await waitFor(() => {
expect(
@@ -1062,7 +1184,7 @@ describe('Match3DResultView', () => {
).toBe(true);
});
test('物品详情五视角预览使用上方焦点区和底部缩略图栏', () => {
test('物品详情五视角预览使用上方大图和底部缩略图栏', () => {
render(
<Match3DResultView
profile={createProfile({
@@ -1079,17 +1201,29 @@ describe('Match3DResultView', () => {
const preview = screen.getByLabelText('物品1五视角预览');
const stage = screen.getByTestId('match3d-item-preview-stage');
const focusFrame = screen.getByTestId('match3d-item-preview-focus-frame');
const focusImage = screen.getByTestId('match3d-item-preview-focus-image');
const thumbnails = screen.getByTestId('match3d-item-preview-thumbnails');
expect(stage.className).toContain('aspect-square');
expect(focusFrame.className).toContain('inset-[7%]');
expect(stage.className).toContain('max-w-[22rem]');
expect(focusImage.className).toContain('place-items-center');
expect(focusImage.querySelector('img')?.className).toContain('p-3');
expect(thumbnails.style.gridAutoColumns).toBe('calc((100% - 1.5rem) / 4)');
expect(preview.querySelectorAll('img')).toHaveLength(10);
expect(preview.querySelectorAll('img')).toHaveLength(6);
expect(
screen
.getByRole('button', { name: '切换物品1视角3' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.queryByTestId('match3d-item-preview-focus-frame'),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '切换物品1视角5' }));
expect(
screen
.getByTestId('match3d-item-preview-focus-image')
.getAttribute('data-preview-src'),
).toContain('views/view-05.png');
});
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
@@ -1216,6 +1350,11 @@ describe('Match3DResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
expect(screen.getByText('1:30')).toBeTruthy();
expect(
document.querySelector(
'img[src="/match3d-background-references/pot-fused-reference.png"]',
),
).toBeTruthy();
});
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -483,7 +483,30 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
);
});
test('容器图换签失败时保留默认圆形容器兜底', async () => {
test('运行态没有生成容器时使用透明参考容器兜底', async () => {
const run = startLocalMatch3DRun(3);
renderRuntime(run, []);
let containerImage!: HTMLImageElement;
await waitFor(() => {
containerImage = screen.getByTestId(
'match3d-container-image',
) as HTMLImageElement;
expect(containerImage.getAttribute('src')).toBe(
'/match3d-background-references/pot-fused-reference.png',
);
});
fireEvent.load(containerImage);
expect(screen.getByTestId('match3d-board').className).toContain(
'bg-transparent',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'rounded-full',
);
});
test('容器图换签失败时使用透明参考容器兜底', async () => {
const run = startLocalMatch3DRun(3);
const generatedItemAssets: Match3DGeneratedItemAsset[] = [
{
@@ -515,12 +538,17 @@ test('容器图换签失败时保留默认圆形容器兜底', async () => {
await waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalled();
});
expect(screen.queryByTestId('match3d-container-image')).toBeNull();
await waitFor(() => {
expect(
screen.getByTestId('match3d-container-image').getAttribute('src'),
).toBe('/match3d-background-references/pot-fused-reference.png');
});
fireEvent.load(screen.getByTestId('match3d-container-image'));
expect(screen.getByTestId('match3d-board').className).toContain(
'rounded-full',
'bg-transparent',
);
expect(screen.getByTestId('match3d-board').className).not.toContain(
'bg-transparent',
'rounded-full',
);
});

View File

@@ -108,6 +108,8 @@ function resolveTrayPreviewItem(
}
const DEFAULT_MATCH3D_MUSIC_VOLUME = 0.42;
const MATCH3D_CONTAINER_REFERENCE_SRC =
'/match3d-background-references/pot-fused-reference.png';
function formatTimer(value: number) {
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
@@ -580,7 +582,7 @@ export function Match3DRuntimeShell({
)
.find(Boolean) ||
'';
const containerAssetSrc =
const generatedContainerAssetSrc =
generatedBackgroundAsset?.containerImageSrc?.trim() ||
generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
runtimeGeneratedItemAssets
@@ -591,6 +593,8 @@ export function Match3DRuntimeShell({
'',
)
.find(Boolean) || '';
const containerAssetSrc =
generatedContainerAssetSrc || MATCH3D_CONTAINER_REFERENCE_SRC;
const imageSourcesByType = useMemo(
() => buildMatch3DImageSourcesByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
@@ -726,12 +730,6 @@ export function Match3DRuntimeShell({
}, [backgroundAssetSrc]);
useEffect(() => {
if (!containerAssetSrc) {
setResolvedContainerImageSrc('');
setIsContainerImageLoaded(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedContainerImageSrc('');
@@ -748,7 +746,11 @@ export function Match3DRuntimeShell({
})
.catch(() => {
if (!cancelled) {
setResolvedContainerImageSrc('');
setResolvedContainerImageSrc(
containerAssetSrc === MATCH3D_CONTAINER_REFERENCE_SRC
? ''
: MATCH3D_CONTAINER_REFERENCE_SRC,
);
setIsContainerImageLoaded(false);
}
});
@@ -911,7 +913,7 @@ export function Match3DRuntimeShell({
style={{
boxSizing: 'border-box',
maxWidth: '100vw',
width: 'min(100vw, 23.5rem)',
width: 'min(100vw, 28rem)',
}}
>
<header className="flex items-center justify-between gap-2">
@@ -946,7 +948,7 @@ export function Match3DRuntimeShell({
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
}`}
style={{
width: 'min(92vw, 58dvh, 100%)',
width: 'min(96vw, 60dvh, 100%)',
}}
onPointerDown={handleBoardPointerDown}
data-testid="match3d-board"
@@ -956,14 +958,18 @@ export function Match3DRuntimeShell({
src={resolvedContainerImageSrc}
alt=""
aria-hidden="true"
className={`pointer-events-none absolute inset-[-8%] z-0 h-[116%] w-[116%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
className={`pointer-events-none absolute inset-[-10%] z-0 h-[120%] w-[120%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
}`}
data-testid="match3d-container-image"
onLoad={() => setIsContainerImageLoaded(true)}
onError={() => {
setIsContainerImageLoaded(false);
setResolvedContainerImageSrc('');
setResolvedContainerImageSrc((currentSrc) =>
currentSrc && currentSrc !== MATCH3D_CONTAINER_REFERENCE_SRC
? MATCH3D_CONTAINER_REFERENCE_SRC
: '',
);
}}
/>
) : (

View File

@@ -473,6 +473,8 @@ const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS;
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
@@ -1576,6 +1578,7 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) {
return collectDraftNoticeKeys('baby-object-match', [
item.id,
item.source.item.profileId,
item.source.item.draftId,
]);
}
}
@@ -1588,6 +1591,15 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) {
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
}
function resolveProfileWalletBalance(
dashboard: { walletBalance?: number | null } | null | undefined,
) {
const walletBalance = dashboard?.walletBalance;
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
? Math.max(0, Math.floor(walletBalance))
: 0;
}
function buildPendingBigFishWorks(
pending: Record<string, PendingDraftShelfState> | undefined,
existingItems: readonly BigFishWorkSummary[],
@@ -1774,6 +1786,7 @@ function buildPuzzleCompileActionFromFormPayload(
promptText: pictureDescription,
...(pictureDescription ? { pictureDescription } : {}),
referenceImageSrc: payload?.referenceImageSrc || null,
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
imageModel: payload?.imageModel ?? null,
aiRedraw: payload?.aiRedraw ?? true,
candidateCount: 1,
@@ -1795,6 +1808,7 @@ function buildPuzzleFormPayloadFromSession(
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
imageModel: null,
aiRedraw: true,
};
@@ -1824,6 +1838,7 @@ function buildPuzzleFormPayloadFromAction(
payload.action === 'compile_puzzle_draft'
? (payload.referenceImageSrc ?? null)
: (payload.referenceImageSrc ?? null),
referenceImageSrcs: payload.referenceImageSrcs ?? [],
imageModel:
payload.action === 'compile_puzzle_draft'
? (payload.imageModel ?? null)
@@ -2649,6 +2664,35 @@ export function PlatformEntryFlowShellImpl({
},
[draftGenerationNotices],
);
const ensureEnoughDraftGenerationPointsFromServer = useCallback(
async (pointsCost: number, setError: (message: string | null) => void) => {
try {
const latestDashboard = await getPlatformProfileDashboard(
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
);
platformBootstrap.setProfileDashboard(latestDashboard);
const walletBalance = resolveProfileWalletBalance(latestDashboard);
if (walletBalance >= pointsCost) {
return true;
}
setError(
`泥点不足,本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`,
);
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
return false;
} catch {
setError('读取泥点余额失败,请稍后重试。');
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
return false;
}
},
[enterCreateTab, platformBootstrap, setSelectionStage],
);
const resolveBigFishErrorMessage = useCallback(
(error: unknown, fallback: string) =>
@@ -3262,8 +3306,15 @@ export function PlatformEntryFlowShellImpl({
...visualNovelShelfItems.flatMap((item) =>
collectDraftNoticeKeys('visual-novel', [item.profileId]),
),
...babyObjectMatchDrafts.flatMap((item) =>
collectDraftNoticeKeys('baby-object-match', [
item.profileId,
item.draftId,
]),
),
],
[
babyObjectMatchDrafts,
bigFishShelfItems,
creationHubItems,
isSquareHoleCreationVisible,
@@ -4318,6 +4369,28 @@ export function PlatformEntryFlowShellImpl({
const persistRpgAgentUiState = sessionController.persistAgentUiState;
const resetAutoSaveTrackingToIdle =
autosaveCoordinator.resetAutoSaveTrackingToIdle;
const preflightPuzzleDraftGeneration = useCallback(async () => {
setPuzzleCreationError(null);
setPuzzleError(null);
return ensureEnoughDraftGenerationPointsFromServer(
PUZZLE_DRAFT_GENERATION_POINT_COST,
(message) => {
setPuzzleCreationError(message);
setPuzzleError(message);
},
);
}, [
ensureEnoughDraftGenerationPointsFromServer,
setPuzzleCreationError,
setPuzzleError,
]);
const preflightMatch3DDraftGeneration = useCallback(async () => {
setMatch3DError(null);
return ensureEnoughDraftGenerationPointsFromServer(
MATCH3D_DRAFT_GENERATION_POINT_COST,
setMatch3DError,
);
}, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]);
const activeMatch3DGenerationSessionId =
selectionStage === 'match3d-generating'
@@ -4519,6 +4592,13 @@ export function PlatformEntryFlowShellImpl({
setPuzzleCreationError(null);
setPuzzleError(null);
if (
payload.aiRedraw !== false &&
!(await preflightPuzzleDraftGeneration())
) {
return;
}
let nextSession: PuzzleAgentSessionSnapshot;
try {
const response = await createPuzzleAgentSession(payload);
@@ -4669,6 +4749,7 @@ export function PlatformEntryFlowShellImpl({
markPendingDraftGenerating,
markPendingDraftReady,
isViewingPuzzleGeneration,
preflightPuzzleDraftGeneration,
puzzleFlow,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
@@ -4684,6 +4765,10 @@ export function PlatformEntryFlowShellImpl({
setStreamingMatch3DReplyText('');
setIsStreamingMatch3DReply(false);
if (!(await preflightMatch3DDraftGeneration())) {
return;
}
let nextSession: Match3DAgentSessionSnapshot;
try {
const response = await match3dCreationClient.createSession(payload);
@@ -4869,6 +4954,7 @@ export function PlatformEntryFlowShellImpl({
markDraftReady,
markPendingDraftGenerating,
markPendingDraftReady,
preflightMatch3DDraftGeneration,
refreshMatch3DShelf,
resolveMatch3DErrorMessage,
setIsStreamingMatch3DReply,
@@ -4970,6 +5056,7 @@ export function PlatformEntryFlowShellImpl({
setBabyObjectMatchGenerationState(
createMiniGameDraftGenerationState('baby-object-match'),
);
selectionStageRef.current = 'baby-object-match-generating';
setSelectionStage('baby-object-match-generating');
try {
@@ -4987,7 +5074,16 @@ export function PlatformEntryFlowShellImpl({
}
: current,
);
setSelectionStage('baby-object-match-result');
const openResult =
selectionStageRef.current === 'baby-object-match-generating';
markDraftReady(
'baby-object-match',
[response.draft.profileId, response.draft.draftId],
openResult,
);
if (openResult) {
setSelectionStage('baby-object-match-result');
}
} catch (error) {
const errorMessage = resolvePuzzleErrorMessage(
error,
@@ -5008,7 +5104,12 @@ export function PlatformEntryFlowShellImpl({
setIsBabyObjectMatchBusy(false);
}
},
[refreshBabyObjectMatchShelf, resolvePuzzleErrorMessage, setSelectionStage],
[
markDraftReady,
refreshBabyObjectMatchShelf,
resolvePuzzleErrorMessage,
setSelectionStage,
],
);
const savePuzzleFormDraft = useCallback(
@@ -5026,6 +5127,7 @@ export function PlatformEntryFlowShellImpl({
promptText: payload.pictureDescription ?? null,
pictureDescription: payload.pictureDescription ?? '',
referenceImageSrc: payload.referenceImageSrc ?? null,
referenceImageSrcs: payload.referenceImageSrcs ?? [],
imageModel: payload.imageModel ?? null,
aiRedraw: payload.aiRedraw ?? true,
});
@@ -5234,7 +5336,6 @@ export function PlatformEntryFlowShellImpl({
setShowCreationTypeModal(false);
setActiveCreationFormType('bark-battle');
setBarkBattleError(null);
setSelectionStage('bark-battle-config');
return;
}
@@ -5272,7 +5373,6 @@ export function PlatformEntryFlowShellImpl({
setMatch3DError,
setPuzzleCreationError,
setPuzzleError,
setSelectionStage,
setVisualNovelError,
],
);
@@ -5306,6 +5406,7 @@ export function PlatformEntryFlowShellImpl({
setBarkBattlePublishedConfig(null);
setBarkBattleError(null);
setIsBarkBattleBusy(false);
selectionStageRef.current = 'platform';
setSelectionStage('platform');
}, [setSelectionStage]);
@@ -5361,6 +5462,7 @@ export function PlatformEntryFlowShellImpl({
setBabyObjectMatchGenerationPhase('generating');
setBabyObjectMatchError(null);
enterCreateTab();
selectionStageRef.current = 'platform';
setSelectionStage('platform');
}, [enterCreateTab, setSelectionStage]);
@@ -8316,19 +8418,24 @@ export function PlatformEntryFlowShellImpl({
const openPuzzleDraft = useCallback(
async (item: PuzzleWorkSummary) => {
const noticeKeys = collectDraftNoticeKeys('puzzle', [
item.workId,
item.profileId,
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]);
const isMarkedGenerating = isDraftNoticeGenerating('puzzle', [
item.workId,
item.profileId,
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]);
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setSelectedPuzzleDetail(null);
markDraftNoticeSeen(
collectDraftNoticeKeys('puzzle', [
item.workId,
item.profileId,
item.sourceSessionId,
buildPuzzleResultWorkId(item.sourceSessionId),
buildPuzzleResultProfileId(item.sourceSessionId),
]),
);
if (!item.sourceSessionId?.trim()) {
if (item.publicationStatus === 'published') {
await openPuzzleDetail(item.profileId, { tab: 'create' });
@@ -8373,6 +8480,30 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (isMarkedGenerating) {
try {
const { session: latestSession } = await getPuzzleAgentSession(
item.sourceSessionId,
);
puzzleFlow.setSession(latestSession);
setPuzzleFormDraftPayload(buildPuzzleFormPayloadFromSession(latestSession));
setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle'));
enterCreateTab();
selectionStageRef.current = 'puzzle-generating';
activePuzzleGenerationSessionIdRef.current = item.sourceSessionId;
setSelectionStage('puzzle-generating');
return;
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图创作草稿失败。'),
);
await refreshPuzzleShelf().catch(() => undefined);
return;
}
}
markDraftNoticeSeen(noticeKeys);
const restoredSession = await puzzleFlow.restoreDraft(
item.sourceSessionId,
);
@@ -8393,12 +8524,14 @@ export function PlatformEntryFlowShellImpl({
[
enterCreateTab,
getPuzzleBackgroundCompileTask,
isDraftNoticeGenerating,
markDraftNoticeSeen,
openPuzzleDetail,
puzzleFlow,
puzzleGenerationViewState,
puzzleSession?.sessionId,
refreshPuzzleShelf,
resolvePuzzleErrorMessage,
setPuzzleError,
setSelectionStage,
],
@@ -8746,6 +8879,12 @@ export function PlatformEntryFlowShellImpl({
const openBabyObjectMatchDraft = useCallback(
(draft: BabyObjectMatchDraft) => {
markDraftNoticeSeen(
collectDraftNoticeKeys('baby-object-match', [
draft.profileId,
draft.draftId,
]),
);
setBabyObjectMatchDraft(draft);
setBabyObjectMatchFormPayload({
itemAName: draft.itemNames[0],
@@ -8757,7 +8896,7 @@ export function PlatformEntryFlowShellImpl({
enterCreateTab();
setSelectionStage('baby-object-match-result');
},
[enterCreateTab, setSelectionStage],
[enterCreateTab, markDraftNoticeSeen, setSelectionStage],
);
const startBigFishRunFromWork = useCallback(
@@ -10601,6 +10740,21 @@ export function PlatformEntryFlowShellImpl({
title={null}
/>
</Suspense>
) : activeCreationFormType === 'bark-battle' ? (
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪创作..." />}
>
<BarkBattleConfigEditor
isBusy={isBarkBattleBusy}
error={barkBattleError}
onBack={leaveBarkBattleFlow}
onPublish={(payload) => {
void publishBarkBattleConfig(payload);
}}
showBackButton={false}
title={null}
/>
</Suspense>
) : (
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
@@ -11182,6 +11336,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule
/>
</Suspense>
</motion.div>
@@ -11848,6 +12003,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
hideBatchModule
/>
</Suspense>
</motion.div>
@@ -12215,33 +12371,6 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-config' && (
<motion.div
key="bark-battle-config"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪配置..." />}
>
<BarkBattleConfigEditor
isBusy={isBarkBattleBusy}
onBack={leaveBarkBattleFlow}
onPublish={(payload) => {
void publishBarkBattleConfig(payload);
}}
/>
{barkBattleError ? (
<div className="platform-subpanel mx-auto mt-3 max-w-5xl rounded-2xl px-4 py-3 text-sm text-rose-200">
{barkBattleError}
</div>
) : null}
</Suspense>
</motion.div>
)}
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
<motion.div
key="bark-battle-runtime"
@@ -12258,7 +12387,9 @@ export function PlatformEntryFlowShellImpl({
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
onExit={() => {
setSelectionStage('bark-battle-config');
enterCreateTab();
setActiveCreationFormType('bark-battle');
setSelectionStage('platform');
}}
/>
</Suspense>

View File

@@ -31,7 +31,6 @@ export type SelectionStage =
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'bark-battle-config'
| 'bark-battle-runtime'
| 'creative-agent-workspace'
| 'visual-novel-agent-workspace'

View File

@@ -189,6 +189,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -273,8 +274,8 @@ test('puzzle workspace selects a history image from the upload card', async () =
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
},
]);
@@ -299,8 +300,11 @@ test('puzzle workspace selects a history image from the upload card', async () =
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText('image.png')).toBeTruthy();
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', { name: / user-1/u }),
await within(picker).findByRole('button', { name: /image\.png/u }),
);
await waitFor(() => {
@@ -310,6 +314,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
'src',
expect.stringContaining('/generated-puzzle-assets/history/image.png'),
);
expect(screen.getByLabelText('画面AI重绘要求提示词')).toBeTruthy();
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留历史图里的主体,改成晴天花园。' },
@@ -321,6 +326,7 @@ test('puzzle workspace selects a history image from the upload card', async () =
seedText: '保留历史图里的主体,改成晴天花园。',
pictureDescription: '保留历史图里的主体,改成晴天花园。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -377,6 +383,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
pictureDescription: '潮雾中的灯塔与断桥',
promptText: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
candidateCount: 1,
@@ -476,6 +483,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
seedText: '旧街灯牌下的猫和发光雨伞。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
@@ -521,6 +529,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
seedText: 'first-level.png',
pictureDescription: 'first-level.png',
referenceImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: false,
});
@@ -559,6 +568,90 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: uploadedDataUrl,
referenceImageSrcs: [],
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace uploads prompt reference images from the description box', async () => {
const onCreateFromForm = vi.fn();
const uploadedSources = [
'data:image/png;base64,reference-1',
'data:image/png;base64,reference-2',
'data:image/png;base64,reference-3',
'data:image/png;base64,reference-4',
'data:image/png;base64,reference-5',
'data:image/png;base64,reference-6',
];
let readIndex = 0;
const firstUploadedSource = uploadedSources[0] || 'data:image/png;base64,reference-1';
stubReferenceImageUpload(firstUploadedSource);
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
uploadedSources[Math.min(readIndex, uploadedSources.length - 1)] ||
firstUploadedSource;
readIndex += 1;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: uploadedSources.map(
(_source, index) =>
new File(['x'], `reference-${index + 1}.png`, {
type: 'image/png',
}),
),
},
});
await waitFor(() => {
expect(screen.getAllByRole('button', { name: //u })).toHaveLength(
5,
);
});
expect(screen.getByText('参考图最多上传 5 张。')).toBeTruthy();
fireEvent.click(
screen.getByRole('button', { name: / reference-1\.png/u }),
);
expect(
await screen.findByRole('dialog', { name: 'reference-1.png' }),
).toBeTruthy();
expect(screen.getByAltText('参考图预览')).toHaveProperty(
'src',
expect.stringContaining('reference-1'),
);
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
confirmPuzzlePointCost();
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '一只猫在雨夜灯牌下回头。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
referenceImageSrcs: uploadedSources.slice(0, 5),
imageModel: 'gpt-image-2',
aiRedraw: true,
});

View File

@@ -1,13 +1,5 @@
import { ArrowLeft } from 'lucide-react';
import {
ArrowLeft,
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
} from 'lucide-react';
import {
type ChangeEvent,
useEffect,
useMemo,
useRef,
@@ -20,11 +12,17 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import {
cropPuzzleReferenceImageDataUrl,
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageAsDataUrl,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import {
CreativeImageInputPanel,
type CreativeImageInputReferenceImage,
} from '../common/CreativeImageInputPanel';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
@@ -57,6 +55,7 @@ type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
referenceImageSrcs: CreativeImageInputReferenceImage[];
imageModel: PuzzleImageModelId;
aiRedraw: boolean;
};
@@ -65,10 +64,13 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
type PuzzleImageCropState = {
source: string;
label: string;
@@ -98,6 +100,9 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload?.referenceImageSrcs,
),
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
aiRedraw: initialFormPayload?.aiRedraw ?? true,
};
@@ -113,6 +118,9 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择拼图图片'
: '',
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
initialFormPayload.referenceImageSrcs,
),
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
aiRedraw: initialFormPayload.aiRedraw ?? true,
};
@@ -131,11 +139,56 @@ function resolveInitialFormState(
'',
referenceImageSrc: '',
referenceImageLabel: '',
referenceImageSrcs: [],
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
aiRedraw: true,
};
}
function normalizePuzzlePromptReferenceSources(
sources: readonly string[] | null | undefined,
) {
const normalizedSources: string[] = [];
for (const source of sources ?? []) {
const normalized = source.trim();
if (
normalized &&
!normalizedSources.some((current) => current === normalized)
) {
normalizedSources.push(normalized);
}
if (normalizedSources.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
break;
}
}
return normalizedSources;
}
function createPuzzlePromptReferenceImagesFromSources(
sources: readonly string[] | null | undefined,
): CreativeImageInputReferenceImage[] {
return normalizePuzzlePromptReferenceSources(sources).map(
(imageSrc, index) => ({
id: `restored:${index}:${imageSrc}`,
label: `参考图 ${index + 1}`,
imageSrc,
}),
);
}
function addPuzzlePromptReferenceImage(
currentImages: CreativeImageInputReferenceImage[],
nextImage: CreativeImageInputReferenceImage,
) {
const deduped = currentImages.filter(
(image) => image.imageSrc !== nextImage.imageSrc,
);
return [...deduped, nextImage].slice(
0,
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT,
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
@@ -160,8 +213,6 @@ export function PuzzleAgentWorkspace({
);
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const [isPointCostConfirmOpen, setIsPointCostConfirmOpen] = useState(false);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
@@ -192,11 +243,19 @@ export function PuzzleAgentWorkspace({
setReferenceImageError(null);
setCropState(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
setIsPointCostConfirmOpen(false);
}, [initialFormPayload, session]);
const pictureDescription = formState.pictureDescription.trim();
const promptReferenceImageSrcs = useMemo(
() =>
normalizePuzzlePromptReferenceSources(
formState.referenceImageSrc
? []
: formState.referenceImageSrcs.map((image) => image.imageSrc),
),
[formState.referenceImageSrc, formState.referenceImageSrcs],
);
const canSubmit = formState.aiRedraw
? Boolean(pictureDescription) && !isBusy
: Boolean(formState.referenceImageSrc) && !isBusy;
@@ -205,6 +264,7 @@ export function PuzzleAgentWorkspace({
seedText: pictureDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
}),
@@ -212,12 +272,14 @@ export function PuzzleAgentWorkspace({
formState.aiRedraw,
formState.referenceImageSrc,
formState.imageModel,
promptReferenceImageSrcs,
pictureDescription,
],
);
const autosaveSignature = JSON.stringify([
autosavePayload.pictureDescription,
autosavePayload.referenceImageSrc,
autosavePayload.referenceImageSrcs,
autosavePayload.aiRedraw,
autosavePayload.imageModel,
]);
@@ -260,15 +322,7 @@ export function PuzzleAgentWorkspace({
session,
]);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
const handleReferenceImageFile = async (file: File) => {
try {
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
@@ -294,7 +348,6 @@ export function PuzzleAgentWorkspace({
referenceImageLabel: file.name.trim() || '本地拼图图片',
}));
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
@@ -304,6 +357,56 @@ export function PuzzleAgentWorkspace({
}
};
const handlePromptReferenceImageFiles = async (files: File[]) => {
if (files.length === 0) {
return;
}
const remainingSlots =
PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT -
formState.referenceImageSrcs.length;
if (remainingSlots <= 0) {
setReferenceImageError('参考图最多上传 5 张。');
return;
}
try {
const images = await Promise.all(
files.slice(0, remainingSlots).map(async (file, index) => ({
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
label: file.name.trim() || `参考图 ${index + 1}`,
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
})),
);
setFormState((current) => ({
...current,
referenceImageSrcs: images.reduce(
addPuzzlePromptReferenceImage,
current.referenceImageSrcs,
),
}));
setReferenceImageError(
files.length > remainingSlots ? '参考图最多上传 5 张。' : null,
);
} catch (uploadError) {
setReferenceImageError(
uploadError instanceof Error
? uploadError.message
: '参考图读取失败,请重试。',
);
}
};
const removePromptReferenceImage = (referenceId: string) => {
setFormState((current) => ({
...current,
referenceImageSrcs: current.referenceImageSrcs.filter(
(image) => image.id !== referenceId,
),
}));
setReferenceImageError(null);
};
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
setCropState((current) => {
if (!current) {
@@ -343,7 +446,6 @@ export function PuzzleAgentWorkspace({
}));
setCropState(null);
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
} catch (cropError) {
setCropState({
...currentCropState,
@@ -381,6 +483,7 @@ export function PuzzleAgentWorkspace({
seedText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
};
@@ -397,12 +500,13 @@ export function PuzzleAgentWorkspace({
promptText: payloadPictureDescription,
pictureDescription: payloadPictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
referenceImageSrcs: promptReferenceImageSrcs,
imageModel: formState.imageModel,
aiRedraw: formState.aiRedraw,
candidateCount: 1,
});
};
const confirmRemoveReferenceImage = () => {
const removeReferenceImage = () => {
setFormState((current) => ({
...current,
referenceImageSrc: '',
@@ -410,11 +514,7 @@ export function PuzzleAgentWorkspace({
aiRedraw: true,
}));
setReferenceImageError(null);
setIsRemoveImageConfirmOpen(false);
};
const pictureDescriptionLabel = formState.referenceImageSrc
? '画面AI重绘要求提示词'
: '画面描述';
return (
<div className="platform-remap-surface puzzle-agent-workspace mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
@@ -434,210 +534,85 @@ export function PuzzleAgentWorkspace({
</div>
) : null}
<div className="puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
) : null}
</div>
) : null}
<section className="puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
<div
className={`puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
formState.aiRedraw
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'flex flex-col lg:grid lg:grid-cols-1'
}`}
>
<div
className={`puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${isBusy ? 'opacity-55' : ''}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="puzzle-image-upload-card relative aspect-square h-full max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition lg:h-auto lg:w-full">
<input
id="puzzle-image-upload-input"
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
aria-label="上传拼图图片"
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="sr-only"
/>
<label
htmlFor="puzzle-image-upload-input"
className={`absolute inset-0 z-0 cursor-pointer ${isBusy ? 'cursor-not-allowed' : ''}`}
title={
formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'
}
>
<span className="sr-only">
{formState.referenceImageSrc
? '更换拼图图片'
: '上传拼图图片'}
</span>
</label>
{formState.referenceImageSrc ? (
<img
src={formState.referenceImageSrc}
alt="拼图图片"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
/>
) : (
<span className="pointer-events-none flex h-full items-center justify-center bg-[radial-gradient(circle_at_50%_28%,rgba(255,255,255,0.9),transparent_38%),linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,241,229,0.86))]">
<span className="flex h-14 w-14 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 text-[var(--platform-text-strong)] shadow-sm sm:h-20 sm:w-20">
<ImagePlus className="h-6 w-6 sm:h-8 sm:w-8" />
</span>
</span>
)}
<div className="absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)] pointer-events-none" />
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-[11px] font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="选择历史图片"
title="选择历史图片"
>
<History className="h-3.5 w-3.5" />
<span></span>
</button>
{formState.referenceImageSrc ? (
<label className="absolute bottom-3 left-3 z-10 inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/80 bg-white/94 px-3 py-2 text-xs font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur">
<span>AI重绘</span>
<input
role="switch"
type="checkbox"
checked={formState.aiRedraw}
disabled={isBusy}
onChange={(event) =>
setFormState((current) => ({
...current,
aiRedraw: event.target.checked,
}))
}
className="sr-only"
aria-label="AI重绘"
/>
<span
aria-hidden="true"
className={`relative h-5 w-9 rounded-full transition ${
formState.aiRedraw ? 'bg-[#ff4056]' : 'bg-zinc-300'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition ${
formState.aiRedraw ? 'left-[1.125rem]' : 'left-0.5'
}`}
/>
</span>
</label>
) : null}
{formState.referenceImageSrc ? (
<button
type="button"
disabled={isBusy}
onClick={() => setIsRemoveImageConfirmOpen(true)}
className="absolute left-3 top-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] disabled:cursor-not-allowed disabled:opacity-55"
aria-label="移除拼图图片"
title="移除拼图图片"
>
<Trash2 className="h-4 w-4" />
</button>
) : (
<label
htmlFor="puzzle-image-upload-input"
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[#ff4056] sm:bottom-10 ${isBusy ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'}`}
>
/
</label>
)}
</div>
</div>
</div>
{formState.aiRedraw ? (
<label className="block shrink-0 lg:min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
{pictureDescriptionLabel}
</span>
<div className="relative">
<textarea
value={formState.pictureDescription}
disabled={isBusy}
rows={2}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
pictureDescription: event.target.value,
}))
}
className="h-[6rem] min-h-[6rem] w-full resize-none rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-14 text-base leading-6 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:h-[7.5rem] sm:min-h-[7.5rem] lg:h-[9.25rem] lg:min-h-[9.25rem]"
aria-label={pictureDescriptionLabel}
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
</div>
</label>
) : null}
</div>
<div className="mt-2 shrink-0 space-y-3">
{referenceImageError ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{referenceImageError}
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
<span>稿</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
</span>
) : null}
</span>
</button>
</div>
<CreativeImageInputPanel
disabled={isBusy}
isSubmitting={isBusy}
uploadedImageSrc={formState.referenceImageSrc}
uploadedImageAlt="拼图图片"
mainImageInputId="puzzle-image-upload-input"
promptTextareaId="puzzle-picture-description-input"
prompt={formState.pictureDescription}
promptLabel={
formState.referenceImageSrc ? '画面AI重绘要求提示词' : '画面描述'
}
promptRows={2}
aiRedraw={formState.aiRedraw}
promptReferenceImages={formState.referenceImageSrcs}
promptReferenceLimit={PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT}
imageModelPicker={
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
}
inputError={referenceImageError}
error={error}
submitLabel="生成拼图游戏草稿"
submitCostLabel={formState.aiRedraw ? '消耗2泥点' : null}
submitDisabled={!canSubmit}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片',
removeImageConfirmBody: '移除后需要重新上传图片',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
history: '选择历史图片',
}}
onMainImageFileSelect={handleReferenceImageFile}
onMainImageRemove={removeReferenceImage}
onAiRedrawChange={(enabled) => {
setFormState((current) => ({
...current,
aiRedraw: enabled,
}));
}}
onPromptChange={(value) => {
setFormState((current) => ({
...current,
pictureDescription: value,
}));
}}
onPromptReferenceFilesSelect={(files) => {
void handlePromptReferenceImageFiles(files);
}}
onPromptReferenceRemove={removePromptReferenceImage}
onHistoryClick={() => setIsHistoryPickerOpen(true)}
onSubmit={submitForm}
/>
{cropState ? (
<SquareImageCropModal
source={cropState.source}
@@ -670,50 +645,15 @@ export function PuzzleAgentWorkspace({
setFormState((current) => ({
...current,
referenceImageSrc: asset.imageSrc,
referenceImageLabel: `历史素材 · ${asset.ownerLabel || '未记录账号'}`,
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
asset.imageSrc,
),
}));
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
setIsRemoveImageConfirmOpen(false);
}}
/>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-image-remove-confirm-title"
className="platform-modal-shell platform-remap-surface w-full max-w-xs rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-image-remove-confirm-title"
className="text-base font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setIsRemoveImageConfirmOpen(false)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={confirmRemoveReferenceImage}
className="platform-button platform-button--primary justify-center"
>
</button>
</div>
</div>
</div>
) : null}
{isPointCostConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div

View File

@@ -6,6 +6,10 @@ import {
puzzleAssetClient,
type PuzzleHistoryAsset,
} from '../../services/puzzle-works/puzzleAssetClient';
import {
formatPuzzleHistoryAssetCreatedAt,
getPuzzleHistoryAssetDisplayName,
} from '../../services/puzzle-works/puzzleHistoryAsset';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -15,22 +19,6 @@ type PuzzleHistoryAssetPickerDialogProps = {
onSelect: (asset: PuzzleHistoryAsset) => void;
};
function formatHistoryAssetDate(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '未知时间';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
export function PuzzleHistoryAssetPickerDialog({
isBusy,
onClose,
@@ -127,31 +115,36 @@ export function PuzzleHistoryAssetPickerDialog({
{!isLoading && assets.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{assets.map((asset) => (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
className="h-full w-full object-cover"
/>
</div>
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{asset.ownerLabel || '未记录账号'}
{assets.map((asset) => {
const displayName = getPuzzleHistoryAssetDisplayName(
asset.imageSrc,
);
return (
<button
key={asset.assetObjectId}
type="button"
disabled={isBusy}
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.25rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={displayName}
className="h-full w-full object-cover"
/>
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatHistoryAssetDate(asset.createdAt)}
<div className="space-y-1 px-4 py-4">
<div className="truncate text-sm font-black text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-base)]">
{formatPuzzleHistoryAssetCreatedAt(asset.createdAt)}
</div>
</div>
</div>
</button>
))}
</button>
);
})}
</div>
) : null}
</div>

View File

@@ -165,6 +165,10 @@ function createSession(
return session;
}
function openPuzzleLevelsTab() {
fireEvent.click(screen.getByRole('button', { name: '拼图关卡' }));
}
describe('PuzzleResultView', () => {
test('renders level list and work info tabs', () => {
render(
@@ -176,14 +180,21 @@ describe('PuzzleResultView', () => {
/>,
);
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByRole('button', { name: '素材配置' })).toBeTruthy();
const workInfoTab = screen.getByRole('button', { name: '作品信息' });
const levelsTab = screen.getByRole('button', { name: '拼图关卡' });
const assetsTab = screen.getByRole('button', { name: '素材配置' });
expect(workInfoTab).toBeTruthy();
expect(levelsTab).toBeTruthy();
expect(assetsTab).toBeTruthy();
expect(
workInfoTab.compareDocumentPosition(levelsTab) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
levelsTab.compareDocumentPosition(assetsTab) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(screen.queryByRole('button', { name: '音乐' })).toBeNull();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(
'value',
'暖灯猫街作品',
@@ -192,6 +203,10 @@ describe('PuzzleResultView', () => {
'value',
'一套雨夜猫街主题拼图。',
);
openPuzzleLevelsTab();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
});
test('result action bar restores draft trial entry', () => {
@@ -276,6 +291,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('关卡名称'), {
@@ -366,6 +382,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(
@@ -427,6 +444,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
@@ -478,6 +496,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
@@ -514,6 +533,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
@@ -534,6 +554,7 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByLabelText('关闭'));
openPuzzleLevelsTab();
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('关闭'));
@@ -976,8 +997,8 @@ describe('PuzzleResultView', () => {
ownerLabel: '账号 user-1',
profileId: null,
entityId: 'puzzle-session-1',
createdAt: '2026-04-27T10:00:00.000Z',
updatedAt: '2026-04-27T10:00:00.000Z',
createdAt: '1713686400.000000Z',
updatedAt: '1713686400.000000Z',
},
]);
@@ -989,6 +1010,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
const uploadInput = within(dialog).getByLabelText('上传参考图', {
@@ -1004,13 +1026,17 @@ describe('PuzzleResultView', () => {
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText('image.png')).toBeTruthy();
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', { name: /账号 user-1/u }),
await within(picker).findByRole('button', { name: /image\.png/u }),
);
await waitFor(() => {
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
});
expect(screen.getByText('历史素材 · image.png')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
@@ -1057,6 +1083,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
@@ -1087,6 +1114,7 @@ describe('PuzzleResultView', () => {
/>,
);
openPuzzleLevelsTab();
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));

View File

@@ -25,6 +25,7 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { updatePuzzleWork } from '../../services/puzzle-works';
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -75,8 +76,8 @@ const PUZZLE_UI_BACKGROUND_REFERENCE_SRC =
'/ui-previews/puzzle-image-compact-ui-2026-05-08.png';
const PUZZLE_RESULT_TABS: Array<{ id: PuzzleResultTab; label: string }> = [
{ id: 'levels', label: '拼图关卡' },
{ id: 'work', label: '作品信息' },
{ id: 'levels', label: '拼图关卡' },
{ id: 'assets', label: '素材配置' },
];
@@ -1006,7 +1007,7 @@ function PuzzleLevelDetailDialog({
onSelect={(asset) => {
setReferenceImageSrc(asset.imageSrc);
setReferenceImageLabel(
`历史素材 · ${asset.ownerLabel || '未记录账号'}`,
getPuzzleHistoryAssetReferenceLabel(asset.imageSrc),
);
setReferenceImageError(null);
setIsHistoryPickerOpen(false);
@@ -1815,7 +1816,7 @@ export function PuzzleResultView({
creativeDraftEdit = null,
}: PuzzleResultViewProps) {
const draft = session.draft;
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('levels');
const [activeTab, setActiveTab] = useState<PuzzleResultTab>('work');
const [activeAssetConfigTab, setActiveAssetConfigTab] =
useState<PuzzleAssetConfigTabId>('ui');
const [activeLevelId, setActiveLevelId] = useState<string | null>(null);

View File

@@ -372,6 +372,117 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
});
});
test('拖拽合并大块时底层单格不显示选中色块', () => {
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
remainingMs: 300_000,
timeLimitMs: 300_000,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
mergedGroups: [
{
groupId: 'group-large',
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
occupiedCells: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
],
},
],
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
? { ...piece, mergedGroupId: 'group-large' }
: piece,
),
},
},
};
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: vi.fn(() => 1),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn(),
});
const { container, unmount } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const board = container.querySelector(
'[data-testid="puzzle-board"]',
) as HTMLElement | null;
if (!board) {
throw new Error('缺少测试棋盘');
}
board.getBoundingClientRect = () =>
({
x: 0,
y: 0,
left: 0,
top: 0,
right: 300,
bottom: 300,
width: 300,
height: 300,
toJSON: () => ({}),
}) as DOMRect;
const mergedPiece = container.querySelector(
'[data-merged-piece-outline="true"]',
) as HTMLElement | null;
if (!mergedPiece) {
throw new Error('缺少测试合并拼图片');
}
act(() => {
dispatchPointerEvent(mergedPiece, 'pointerdown', {
pointerId: 13,
clientX: 60,
clientY: 60,
});
});
act(() => {
dispatchPointerEvent(mergedPiece, 'pointermove', {
pointerId: 13,
clientX: 210,
clientY: 210,
});
});
const basePiece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
expect(basePiece?.className).not.toContain(
'puzzle-runtime-piece--selected',
);
unmount();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: originalCancelAnimationFrame,
});
});
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();

View File

@@ -1329,7 +1329,8 @@ export function PuzzleRuntimeShell({
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
const occupied = Boolean(piece);
const isMerged = mergedCellKeys.has(boardCellKey(cell));
const isSelected = piece?.pieceId === selectedPieceId;
const isSelected =
!isMerged && piece?.pieceId === selectedPieceId;
return (
<div

View File

@@ -7,6 +7,10 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
@@ -37,6 +41,14 @@ import {
} from '../../routing/appPageRoutes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import {
createBigFishCreationSession,
getBigFishCreationSession,
@@ -48,6 +60,10 @@ import {
submitBigFishInput,
} from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
type CreationEntryConfig,
fetchCreationEntryConfig,
@@ -193,9 +209,9 @@ async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
async function expectDraftHubGeneratingBadgeCountAtLeast(count: number) {
const panel = getPlatformTabPanel('saves');
await waitFor(() => {
expect(within(panel).getAllByText('生成中').length).toBeGreaterThanOrEqual(
count,
);
expect(
within(panel).getAllByLabelText('生成中').length,
).toBeGreaterThanOrEqual(count);
});
}
@@ -277,6 +293,17 @@ const testCreationEntryConfig = {
sortOrder: 40,
updatedAtMicros: 1,
},
{
id: 'bark-battle',
title: '汪汪声浪',
subtitle: '声控狗狗对战',
badge: '可创建',
imageSrc: '/creation-type-references/bark-battle.webp',
visible: true,
open: true,
sortOrder: 45,
updatedAtMicros: 1,
},
{
id: 'square-hole',
title: '方洞挑战',
@@ -446,6 +473,21 @@ vi.mock('../../services/big-fish-runtime', () => ({
submitBigFishInput: vi.fn(),
}));
vi.mock('../../services/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(),
publishBarkBattleWork: vi.fn(),
}));
vi.mock('../../services/edutainment-baby-object', () => ({
createBabyObjectMatchDraft: vi.fn(),
deleteLocalBabyObjectMatchDraft: vi.fn(),
hasBabyObjectMatchPlaceholderAssets: vi.fn(() => false),
listLocalBabyObjectMatchDrafts: vi.fn(),
publishBabyObjectMatchWork: vi.fn(),
regenerateBabyObjectMatchDraftAssets: vi.fn(),
saveBabyObjectMatchDraft: vi.fn(),
}));
vi.mock('../../services/match3d-creation', () => ({
match3dCreationClient: {
createSession: vi.fn(),
@@ -806,10 +848,12 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
isBusy,
error,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
isBusy?: boolean;
error?: string | null;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
@@ -827,6 +871,7 @@ vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
<div data-testid="match3d-workspace-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{error ? <div>{error}</div> : null}
<button
type="button"
disabled={isBusy}
@@ -938,6 +983,125 @@ vi.mock('../match3d-runtime/Match3DRuntimeShell', () => ({
),
}));
vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
BarkBattleConfigEditor: ({
error,
isBusy,
showBackButton,
title,
onPublish,
}: {
error?: string | null;
isBusy?: boolean;
showBackButton?: boolean;
title?: string | null;
onPublish: (payload: {
title: string;
description: string;
themePreset: string;
playerDogSkinPreset: string;
opponentDogSkinPreset: string;
difficultyPreset: 'normal';
leaderboardEnabled: boolean;
}) => void;
}) => (
<div className="bark-battle-config-editor-mock">
<div></div>
<div data-testid="bark-battle-editor-back-state">
{showBackButton ? 'back-visible' : 'back-hidden'}
</div>
<div data-testid="bark-battle-editor-title-state">
{title === null ? 'title-hidden' : title}
</div>
<div data-testid="bark-battle-editor-busy-state">
{isBusy ? 'busy' : 'idle'}
</div>
{error ? <div>{error}</div> : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onPublish({
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'normal',
leaderboardEnabled: true,
});
}}
>
</button>
</div>
),
}));
vi.mock('../edutainment-result/BabyObjectMatchResultView', () => ({
BabyObjectMatchResultView: ({
draft,
onBack,
onStartTestRun,
}: {
draft: BabyObjectMatchDraft;
onBack: () => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
}) => (
<div className="baby-object-match-result-view-mock">
<div></div>
<div>{draft.workTitle}</div>
<button
type="button"
onClick={() => {
onStartTestRun?.(draft);
}}
>
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../edutainment-runtime/BabyObjectMatchRuntimeShell', () => ({
BabyObjectMatchRuntimeShell: ({
draft,
onBack,
}: {
draft: BabyObjectMatchDraft;
onBack?: () => void;
}) => (
<div className="baby-object-match-runtime-shell-mock">
<div>{draft.profileId}</div>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../../games/bark-battle/ui/BarkBattleRuntimeShell', () => ({
BarkBattleRuntimeShell: ({
title,
workId,
onExit,
}: {
title?: string;
workId?: string;
onExit?: () => void;
}) => (
<div className="bark-battle-runtime-shell-mock">
<div>{title ?? '未命名'} / {workId ?? 'missing-work'}</div>
<button type="button" onClick={onExit}>
</button>
</div>
),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
@@ -1070,6 +1234,48 @@ function buildMockCreativeAgentSession(
};
}
function buildMockBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
const itemNames = overrides.itemNames ?? ['苹果', '香蕉'];
const now = '2026-05-14T10:00:00.000Z';
return {
draftId: 'baby-object-draft-red-dot',
profileId: 'baby-object-profile-red-dot',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物红点草稿',
workDescription: `${itemNames[0]}${itemNames[1]}识物分类`,
itemNames,
itemAssets: [
{
itemId: 'baby-object-item-a',
itemName: itemNames[0],
imageSrc: '/baby-object/apple.png',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: itemNames[0],
},
{
itemId: 'baby-object-item-b',
itemName: itemNames[1],
imageSrc: '/baby-object/banana.png',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: itemNames[1],
},
],
visualPackage: null,
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'draft',
createdAt: now,
updatedAt: now,
publishedAt: null,
...overrides,
};
}
function buildMockSquareHoleAgentSession(
overrides: Partial<
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
@@ -1908,7 +2114,7 @@ beforeEach(() => {
testCreationEntryConfig,
);
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 0,
walletBalance: 20,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-04-16T12:00:00.000Z',
@@ -2006,6 +2212,22 @@ beforeEach(() => {
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
vi.mocked(saveBabyObjectMatchDraft).mockImplementation(async (payload) => ({
draft: payload.draft,
}));
vi.mocked(regenerateBabyObjectMatchDraftAssets).mockImplementation(
async (draft) => ({ draft }),
);
vi.mocked(publishBabyObjectMatchWork).mockImplementation(async (payload) => ({
draft: {
...payload.draft,
publicationStatus: 'published',
publishedAt: '2026-05-14T10:10:00.000Z',
},
publicWorkCode: `BO-${payload.draft.profileId}`,
}));
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(recordRpgEntryWorldGalleryPlay).mockImplementation(
async (ownerUserId, profileId) => ({
@@ -2502,6 +2724,36 @@ beforeEach(() => {
},
}));
vi.mocked(recordBigFishPlay).mockResolvedValue({ items: [] });
vi.mocked(createBarkBattleDraft).mockResolvedValue({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'normal',
leaderboardEnabled: true,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
});
vi.mocked(publishBarkBattleWork).mockResolvedValue({
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z',
});
vi.mocked(match3dCreationClient.createSession).mockResolvedValue({
session: buildMockMatch3DAgentSession(),
});
@@ -2885,6 +3137,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).querySelector('img')?.src,
).toContain('/creation-type-references/bark-battle.webp');
expect(
screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src,
).toContain('/child-motion-demo/picture-book-grass-stage.png');
@@ -2900,6 +3155,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /汪汪声浪/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
@@ -2922,6 +3178,57 @@ test('create tab switches match3d into the embedded entry form', async () => {
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('create tab switches bark battle into the embedded config form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
).toBe('true');
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.getByTestId('bark-battle-editor-back-state').textContent).toBe(
'back-hidden',
);
expect(screen.getByTestId('bark-battle-editor-title-state').textContent).toBe(
'title-hidden',
);
expect(screen.queryByText('汪汪声浪运行态')).toBeNull();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
});
test('bark battle publish preview returns to the embedded config form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '发布并试玩' }));
expect(createBarkBattleDraft).toHaveBeenCalledWith({
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'normal',
leaderboardEnabled: true,
});
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回配置' }));
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
).toBe('true');
});
test('running match3d form generation can return to draft tab and reopen progress', async () => {
const user = userEvent.setup();
const runningSession = buildMockMatch3DAgentSession({
@@ -3394,6 +3701,116 @@ test('running puzzle form generation creates a new puzzle draft on same template
});
});
test('running puzzle draft opens generation progress from draft tab', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-running-session',
draft: null,
stage: 'collecting_anchors',
progressPercent: 20,
});
let resolveCompile!: (value: {
operation: {
operationId: string;
type: 'compile_puzzle_draft';
status: 'completed';
phaseLabel: string;
phaseDetail: string;
progress: number;
};
session: PuzzleAgentSessionSnapshot;
}) => void;
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: runningSession,
});
vi.mocked(executePuzzleAgentAction).mockReturnValueOnce(
new Promise((resolve) => {
resolveCompile = resolve;
}),
);
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: runningSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await expectDraftHubGeneratingBadgeCountAtLeast(1);
await user.click(
screen.getByRole('button', { name: /继续创作《拼图草稿》/u }),
);
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
await act(async () => {
resolveCompile({
operation: {
operationId: 'compile-puzzle-running',
type: 'compile_puzzle_draft',
status: 'completed',
phaseLabel: '已完成',
phaseDetail: '草稿已生成',
progress: 1,
},
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-running-session',
}),
});
});
});
test('puzzle form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 1,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
await screen.findByText('泥点不足,本次需要 2 泥点,当前 1 泥点。'),
).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(executePuzzleAgentAction).not.toHaveBeenCalled();
});
test('match3d form checks mud points before creating a draft', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 9,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(
await screen.findByRole('button', { name: '生成抓大鹅草稿' }),
);
expect(
await screen.findByText('泥点不足,本次需要 10 泥点,当前 9 泥点。'),
).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(match3dCreationClient.executeAction).not.toHaveBeenCalled();
});
test('match3d result trial passes generated models into first runtime mount', async () => {
const user = userEvent.setup();
const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [
@@ -3857,6 +4274,113 @@ test('completed match3d draft notice first opens trial then reopens result', asy
expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1);
});
test('completed baby object match draft viewed immediately does not keep unread marker', async () => {
const user = userEvent.setup();
const generatedDraft = buildMockBabyObjectMatchDraft();
vi.mocked(createBabyObjectMatchDraft).mockImplementation(
async (payload: CreateBabyObjectMatchDraftRequest) => ({
draft: buildMockBabyObjectMatchDraft({
itemNames: [payload.itemAName, payload.itemBName],
}),
}),
);
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([generatedDraft]);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await waitFor(() => {
expect(
screen.getByRole('tab', { name: '宝贝识物' }).getAttribute(
'aria-selected',
),
).toBe('true');
});
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
expect(await screen.findByLabelText('物品 A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(
await screen.findByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
).toBeTruthy();
expect(screen.queryByLabelText('新生成完成')).toBeNull();
await user.click(
screen.getByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
);
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
test('completed baby object match draft shows unread marker after leaving generation page', async () => {
const user = userEvent.setup();
const generatedDraft = buildMockBabyObjectMatchDraft();
let resolveCreateDraft!: (value: { draft: BabyObjectMatchDraft }) => void;
vi.mocked(createBabyObjectMatchDraft).mockReturnValue(
new Promise((resolve) => {
resolveCreateDraft = resolve;
}),
);
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([generatedDraft]);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '宝贝识物' }));
await user.type(await screen.findByLabelText('物品 A'), '苹果');
await user.type(await screen.findByLabelText('物品 B'), '香蕉');
await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' }));
expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
await openDraftHub(user);
await act(async () => {
resolveCreateDraft({ draft: generatedDraft });
});
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
expect(
await screen.findByRole('button', { name: '草稿,有新草稿' }),
).toBeTruthy();
await user.click(
await screen.findByRole('button', {
name: /继续创作《宝贝识物红点草稿》/u,
}),
);
expect(await screen.findByText('宝贝识物结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await waitFor(() => {
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
});
test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => {
const user = userEvent.setup();
const generatedDraft: PuzzleResultDraft = {
@@ -7943,7 +8467,7 @@ test('creation hub published work experience button enters world directly', asyn
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work card keeps delete action guarded by detail flow', async () => {
test('creation hub published work card reveals delete action after card action reveal', async () => {
const user = userEvent.setup();
const publishedWork = {
@@ -8014,7 +8538,13 @@ test('creation hub published work card keeps delete action guarded by detail flo
await openDraftHub(user);
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
const publishedCard = await screen.findByRole('button', {
name: /查看详情《潮雾列岛》/u,
});
publishedCard.focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除' }));
const dialog = await screen.findByRole('dialog', { name: '删除作品' });

View File

@@ -949,10 +949,7 @@ test('profile recharge modal buys points through mock channel outside mini progr
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(screen.getByRole('button', { name: /\s*\//u }));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
@@ -1022,10 +1019,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
});
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(screen.getByRole('button', { name: /\s*\//u }));
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
@@ -1302,6 +1296,9 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();

View File

@@ -5196,12 +5196,6 @@ export function RpgEntryHomeView({
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label="充值"
subLabel="泥点/会员"
icon={Coins}
onClick={openRechargeModal}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"

View File

@@ -55,16 +55,23 @@ body {
}
.platform-viewport-shell {
height: 100vh;
max-height: 100vh;
min-height: 100vh;
height: var(--platform-layout-viewport-height, 100vh);
max-height: var(--platform-layout-viewport-height, 100vh);
min-height: var(--platform-layout-viewport-height, 100vh);
transform: translate3d(
0,
calc(-1 * var(--platform-keyboard-focus-offset, 0px)),
0
);
transform-origin: top center;
transition: transform 180ms ease;
}
@supports (height: 100dvh) {
.platform-viewport-shell {
height: 100dvh;
max-height: 100dvh;
min-height: 100dvh;
height: var(--platform-layout-viewport-height, 100dvh);
max-height: var(--platform-layout-viewport-height, 100dvh);
min-height: var(--platform-layout-viewport-height, 100dvh);
}
}
@@ -908,6 +915,15 @@ body {
.platform-mobile-bottom-dock {
flex: none;
transition:
opacity 160ms ease,
transform 160ms ease;
}
html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
pointer-events: none;
opacity: 0;
transform: translateY(0.75rem);
}
.platform-tab-panel {
@@ -1518,23 +1534,23 @@ body {
overflow: hidden;
border-radius: 0.9rem;
background: transparent;
--creation-work-card-action-opacity: 0;
}
.creation-work-card {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(4.75rem, 20%);
align-items: center;
gap: 0.78rem;
display: block;
min-width: 0;
width: 100%;
min-height: 6.15rem;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.9rem;
background: color-mix(in srgb, var(--platform-subpanel-fill) 88%, #050506 12%);
padding: 0.55rem 0.6rem 0.55rem 0.55rem;
background: color-mix(
in srgb,
var(--platform-subpanel-fill) 88%,
#050506 12%
);
padding: 0.55rem 0.58rem 0.55rem 0.6rem;
color: var(--platform-text-base);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08);
touch-action: pan-y;
@@ -1556,15 +1572,15 @@ body {
}
.creation-work-card--draft {
background: color-mix(
in srgb,
var(--platform-subpanel-fill) 92%,
#040506 8%
);
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #040506 8%);
}
.creation-work-card--generating {
border-color: color-mix(in srgb, var(--platform-cool-border) 64%, transparent);
border-color: color-mix(
in srgb,
var(--platform-cool-border) 64%,
transparent
);
}
.creation-work-card--swiping {
@@ -1573,6 +1589,8 @@ body {
.creation-work-card.platform-interactive-card:hover {
transform: translateX(var(--creation-work-card-swipe-offset, 0)) translateY(-2px);
border-color: var(--platform-surface-hover-border);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.14);
}
.creation-work-card.platform-interactive-card:active,
@@ -1635,56 +1653,45 @@ body {
}
.creation-work-card__side-cover {
position: relative;
aspect-ratio: 1;
min-width: 0;
width: 100%;
max-width: 5.6rem;
justify-self: end;
position: absolute;
inset: 0 0 0 auto;
z-index: 0;
width: 58%;
overflow: hidden;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.86rem;
background:
linear-gradient(
135deg,
rgba(255, 249, 251, 0.58),
rgba(255, 218, 207, 0.26)
),
var(--creation-work-card-cover-fallback),
var(--platform-subpanel-fill);
background: var(--creation-work-card-cover-fallback), transparent;
background-position: center;
background-size: cover;
opacity: 0.68;
pointer-events: none;
}
.creation-work-card__side-cover::after {
content: '';
.creation-work-card__side-cover-inner {
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--platform-subpanel-fill) 42%, transparent),
rgba(8, 10, 14, 0.04)
);
overflow: hidden;
border-radius: 0;
opacity: 1;
}
.creation-work-card__side-cover .custom-world-cover-artwork {
inset: 0;
background:
var(--creation-work-card-cover-fallback),
linear-gradient(135deg, #fff9fb 0%, #ffe8f0 48%, #ffdacf 100%);
background: transparent;
background-position: center;
background-size: cover;
}
.creation-work-card__side-cover .custom-world-cover-artwork > div:first-of-type {
opacity: 0.18;
}
.creation-work-card__body {
display: flex;
position: relative;
z-index: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
justify-content: center;
gap: 0.34rem;
gap: 0.38rem;
align-self: stretch;
}
@@ -1693,7 +1700,43 @@ body {
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 0.45rem;
gap: 0.4rem;
}
.creation-work-card__title-lockup {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 0.42rem;
}
.creation-work-card__state-mark {
display: inline-flex;
width: 1.15rem;
height: 1.15rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 9999px;
}
.creation-work-card__state-mark--draft {
border-color: var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-warm-text);
}
.creation-work-card__state-mark--published {
border-color: var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.creation-work-card__state-mark--generating {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
}
.creation-work-card__title {
@@ -1703,45 +1746,13 @@ body {
font-size: 0.98rem;
font-weight: 900;
line-height: 1.18;
flex: 1 1 auto;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.creation-work-card__status-pill {
display: inline-flex;
min-height: 1.22rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 9999px;
padding: 0 0.4rem;
font-size: 0.68rem;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.creation-work-card__status-pill--draft {
border-color: var(--platform-warm-border);
background: var(--platform-warm-bg);
color: var(--platform-warm-text);
}
.creation-work-card__status-pill--published {
border-color: var(--platform-success-border);
background: var(--platform-success-bg);
color: var(--platform-success-text);
}
.creation-work-card__status-pill--generating {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
}
.creation-work-card__meta {
display: flex;
min-width: 0;
@@ -3629,11 +3640,9 @@ body {
.creation-work-card,
.creation-work-card.platform-category-game-item {
grid-template-columns: minmax(0, 1fr) 5.1rem;
gap: 0.72rem;
min-height: 6.65rem;
border-radius: 0.86rem;
padding: 0.58rem 0.56rem 0.58rem 0.68rem;
padding: 0.58rem 0.58rem 0.58rem 0.68rem;
}
.creation-work-card-shell {
@@ -3641,11 +3650,7 @@ body {
}
.creation-work-card__side-cover {
align-self: center;
width: 5.1rem;
max-width: 5.1rem;
border-radius: 0.78rem;
opacity: 0.72;
width: 60%;
}
.creation-work-card__body {
@@ -3666,12 +3671,6 @@ body {
overflow-wrap: anywhere;
}
.creation-work-card__status-pill {
min-height: 1.15rem;
padding-inline: 0.34rem;
font-size: 0.62rem;
}
.creation-work-card__meta {
gap: 0.26rem;
}
@@ -3780,9 +3779,7 @@ body {
.platform-mobile-entry-shell--recommend {
padding-top: 0;
padding-bottom: calc(
var(--platform-bottom-dock-outer-height) - 0.95rem
);
padding-bottom: calc(var(--platform-bottom-dock-outer-height) - 0.95rem);
}
.platform-mobile-bottom-dock {
@@ -3970,7 +3967,7 @@ body {
.platform-recommend-swipe-card__meta {
position: relative;
z-index: 2;
flex: 0 0 clamp(6.8rem, 18dvh, 8.4rem);
flex: 0 0 clamp(5.2rem, 13.5dvh, 6.1rem);
min-width: 0;
border: 1px solid var(--platform-recommend-runtime-border);
border-top: 0;
@@ -4078,7 +4075,7 @@ body {
align-items: center;
flex: 0 0 auto;
min-width: 0;
padding: 0.68rem 0.78rem 1.12rem;
padding: 0.52rem 0.72rem 0.72rem;
color: var(--platform-text-strong);
touch-action: none;
user-select: none;
@@ -4088,14 +4085,14 @@ body {
display: flex;
min-width: 0;
align-items: center;
gap: 0.55rem;
gap: 0.44rem;
padding: 0;
}
.platform-recommend-work-meta__action {
display: inline-flex;
min-width: 0;
min-height: 2.4rem;
min-height: 2.22rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(121, 82, 109, 0.12);
@@ -4120,8 +4117,8 @@ body {
}
.platform-recommend-work-meta__action--icon {
width: 2.4rem;
flex: 0 0 2.4rem;
width: 2.22rem;
flex: 0 0 2.22rem;
}
.platform-recommend-work-meta__action--like {
@@ -4408,10 +4405,7 @@ body {
.creation-work-card,
.creation-work-card.platform-category-game-item {
grid-template-columns: minmax(0, 1fr) minmax(5.5rem, 32%);
min-height: 10.75rem;
align-items: stretch;
gap: 0.95rem;
border-radius: 1.05rem;
padding: 0.85rem;
}
@@ -4434,16 +4428,9 @@ body {
}
.creation-work-card__side-cover {
max-width: none;
height: 100%;
aspect-ratio: auto;
border-radius: 0.9rem;
opacity: 0.38;
width: 62%;
}
.creation-work-card__swipe-button {
width: 5rem;
}
}
@media (min-width: 1180px) {
@@ -4929,6 +4916,28 @@ body {
background: var(--platform-track-fill);
}
@keyframes custom-world-generation-step-slide-in {
from {
opacity: 0;
transform: translateX(-110vw);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media (max-width: 639px) {
.custom-world-generation-step {
animation-name: custom-world-generation-step-slide-in;
animation-duration: 0.38s;
animation-timing-function: ease-out;
animation-delay: var(--generation-step-delay, 0ms);
animation-fill-mode: both;
}
}
.platform-cover-artwork {
background: radial-gradient(
circle at top,

View File

@@ -5,6 +5,7 @@ import './index.css';
import {StrictMode, Suspense} from 'react';
import {createRoot} from 'react-dom/client';
import {stabilizeMobileViewportKeyboardFocus} from './mobileViewportKeyboardFocus';
import {lockMobileViewportZoom} from './mobileViewportZoomLock';
import {resolveAppRoute} from './routing/appRoutes';
import {RouteImageReadyGate} from './routing/RouteImageReadyGate';
@@ -29,6 +30,7 @@ const root = window.__tavernRealmsRoot__ ??= createRoot(rootElement);
const RouteComponent = route.Component;
lockMobileViewportZoom();
stabilizeMobileViewportKeyboardFocus();
root.render(
<StrictMode>

View File

@@ -0,0 +1,77 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
import {
calculateMobileKeyboardFocusShift,
isEditableKeyboardTarget,
} from './mobileViewportKeyboardFocus';
describe('isEditableKeyboardTarget', () => {
it('matches controls that open the mobile keyboard', () => {
const input = document.createElement('input');
input.type = 'text';
const textarea = document.createElement('textarea');
const buttonInput = document.createElement('input');
buttonInput.type = 'file';
expect(isEditableKeyboardTarget(input)).toBe(true);
expect(isEditableKeyboardTarget(textarea)).toBe(true);
expect(isEditableKeyboardTarget(buttonInput)).toBe(false);
});
it('ignores disabled and readonly controls', () => {
const disabledInput = document.createElement('input');
disabledInput.disabled = true;
const readonlyInput = document.createElement('input');
readonlyInput.readOnly = true;
expect(isEditableKeyboardTarget(disabledInput)).toBe(false);
expect(isEditableKeyboardTarget(readonlyInput)).toBe(false);
});
});
describe('calculateMobileKeyboardFocusShift', () => {
it('moves a bottom input above the visible keyboard area', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 720,
targetBottom: 770,
currentShift: 0,
margin: 20,
}),
).toBe(290);
});
it('does not move when the focused input is already visible', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 250,
targetBottom: 300,
currentShift: 0,
margin: 20,
}),
).toBe(0);
});
it('caps movement to keyboard inset plus safety margin', () => {
expect(
calculateMobileKeyboardFocusShift({
layoutHeight: 800,
visualTop: 0,
visualHeight: 500,
targetTop: 790,
targetBottom: 860,
currentShift: 0,
margin: 20,
maxExtraShift: 20,
}),
).toBe(320);
});
});

View File

@@ -0,0 +1,261 @@
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
const FOCUS_MARGIN_PX = 18;
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
type KeyboardFocusShiftInput = {
layoutHeight: number;
visualTop: number;
visualHeight: number;
targetTop: number;
targetBottom: number;
currentShift: number;
margin?: number;
maxExtraShift?: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function readVisualViewport() {
return typeof window !== 'undefined' ? window.visualViewport : undefined;
}
function readLayoutViewportHeight() {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
}
const visualViewport = readVisualViewport();
return Math.max(
window.innerHeight || 0,
document.documentElement.clientHeight || 0,
visualViewport?.height ?? 0,
MIN_LAYOUT_VIEWPORT_HEIGHT_PX,
);
}
function shouldHandleMobileKeyboardFocus() {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
return (
navigator.maxTouchPoints > 0 ||
window.matchMedia(MOBILE_POINTER_QUERY).matches
);
}
function isDisabledEditableControl(element: HTMLElement) {
return (
'disabled' in element &&
Boolean((element as HTMLInputElement | HTMLTextAreaElement).disabled)
);
}
function isReadOnlyEditableControl(element: HTMLElement) {
return (
'readOnly' in element &&
Boolean((element as HTMLInputElement | HTMLTextAreaElement).readOnly)
);
}
/**
* 中文注释:只把会唤起移动端输入法的控件纳入键盘聚焦处理。
* 文件、滑块、复选框等输入控件不需要挪动画布。
*/
export function isEditableKeyboardTarget(
element: Element | null,
): element is HTMLElement {
if (
typeof HTMLElement === 'undefined' ||
!(element instanceof HTMLElement)
) {
return false;
}
if (element.isContentEditable) {
return true;
}
if (isDisabledEditableControl(element) || isReadOnlyEditableControl(element)) {
return false;
}
if (element instanceof HTMLTextAreaElement) {
return true;
}
if (!(element instanceof HTMLInputElement)) {
return false;
}
const inputType = (element.type || 'text').toLowerCase();
return !new Set([
'button',
'checkbox',
'color',
'file',
'hidden',
'image',
'radio',
'range',
'reset',
'submit',
]).has(inputType);
}
export function calculateMobileKeyboardFocusShift({
layoutHeight,
visualTop,
visualHeight,
targetTop,
targetBottom,
currentShift,
margin = FOCUS_MARGIN_PX,
maxExtraShift = FOCUS_MARGIN_PX,
}: KeyboardFocusShiftInput) {
const visualBottom = visualTop + visualHeight;
const safeTop = visualTop + margin;
const safeBottom = visualBottom - margin;
const unshiftedTargetTop = targetTop + currentShift;
const unshiftedTargetBottom = targetBottom + currentShift;
let nextShift = currentShift;
if (unshiftedTargetBottom - nextShift > safeBottom) {
nextShift = unshiftedTargetBottom - safeBottom;
}
if (unshiftedTargetTop - nextShift < safeTop) {
nextShift = Math.max(0, unshiftedTargetTop - safeTop);
}
const keyboardInset = Math.max(0, layoutHeight - visualBottom);
const maxShift = keyboardInset + maxExtraShift;
return Math.round(clamp(nextShift, 0, Math.max(0, maxShift)));
}
export function stabilizeMobileViewportKeyboardFocus() {
if (
typeof document === 'undefined' ||
!shouldHandleMobileKeyboardFocus() ||
document.documentElement.dataset.mobileViewportKeyboardFocus === 'true'
) {
return;
}
document.documentElement.dataset.mobileViewportKeyboardFocus = 'true';
const root = document.documentElement;
const visualViewport = readVisualViewport();
let stableLayoutHeight = readLayoutViewportHeight();
let currentShift = 0;
let frameId = 0;
const setLayoutHeight = (nextHeight: number) => {
stableLayoutHeight = Math.max(
MIN_LAYOUT_VIEWPORT_HEIGHT_PX,
Math.round(nextHeight),
);
root.style.setProperty(LAYOUT_HEIGHT_VAR, `${stableLayoutHeight}px`);
};
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
if (isOpen) {
root.dataset.mobileKeyboardOpen = 'true';
} else {
delete root.dataset.mobileKeyboardOpen;
}
root.style.setProperty(
KEYBOARD_INSET_VAR,
`${Math.max(0, Math.round(insetBottom))}px`,
);
};
const setFocusShift = (nextShift: number) => {
currentShift = Math.max(0, Math.round(nextShift));
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`);
};
const readActiveTarget = () =>
isEditableKeyboardTarget(document.activeElement)
? document.activeElement
: null;
const syncKeyboardFocus = () => {
const activeTarget = readActiveTarget();
const viewport = readVisualViewport();
const visualTop = viewport?.offsetTop ?? 0;
const visualHeight = viewport?.height ?? window.innerHeight;
const visualBottom = visualTop + visualHeight;
const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom);
const keyboardOpen =
Boolean(activeTarget) &&
stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX;
if (!keyboardOpen || !activeTarget) {
setKeyboardState(false);
setFocusShift(0);
if (!activeTarget) {
setLayoutHeight(readLayoutViewportHeight());
}
return;
}
// 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘。
const targetRect = activeTarget.getBoundingClientRect();
const nextShift = calculateMobileKeyboardFocusShift({
layoutHeight: stableLayoutHeight,
visualTop,
visualHeight,
targetTop: targetRect.top,
targetBottom: targetRect.bottom,
currentShift,
});
setKeyboardState(true, keyboardInset);
setFocusShift(nextShift);
};
const scheduleSync = () => {
if (frameId) {
window.cancelAnimationFrame(frameId);
}
frameId = window.requestAnimationFrame(() => {
frameId = 0;
syncKeyboardFocus();
});
};
const scheduleKeyboardAnimationSync = () => {
scheduleSync();
window.setTimeout(scheduleSync, 90);
window.setTimeout(scheduleSync, 260);
};
setLayoutHeight(stableLayoutHeight);
setKeyboardState(false);
setFocusShift(0);
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
window.addEventListener('resize', scheduleKeyboardAnimationSync);
window.addEventListener('orientationchange', () => {
setKeyboardState(false);
setFocusShift(0);
window.setTimeout(() => {
setLayoutHeight(readLayoutViewportHeight());
scheduleSync();
}, 320);
});
visualViewport?.addEventListener('resize', scheduleKeyboardAnimationSync);
visualViewport?.addEventListener('scroll', scheduleKeyboardAnimationSync);
}

View File

@@ -587,6 +587,49 @@ describe('apiClient', () => {
});
});
it('prefers api error details.reason over details.message for diagnostics', async () => {
setStoredAccessToken('details-reason-first-token', { emit: false });
fetchMock.mockResolvedValueOnce(
createResponseMock({
status: 502,
body: JSON.stringify({
ok: false,
data: null,
error: {
code: 'UPSTREAM_ERROR',
message: '上游暂不可用',
details: {
provider: 'vector-engine',
message:
'创建拼图 VectorEngine 图片编辑任务失败error sending request for url (https://api.vectorengine.ai/v1/images/edits)',
reason:
'无法连接 VectorEngine 图片编辑接口请检查服务器网络、DNS、防火墙或代理配置',
endpoint: 'https://api.vectorengine.ai/v1/images/edits',
},
},
meta: {},
}),
headers: {
'Content-Type': 'application/json',
},
}),
);
await expect(
requestJson('/api/runtime/puzzle/agent/sessions/test/actions', {
method: 'POST',
}, '执行拼图操作失败。'),
).rejects.toMatchObject({
message:
'无法连接 VectorEngine 图片编辑接口请检查服务器网络、DNS、防火墙或代理配置',
status: 502,
code: 'UPSTREAM_ERROR',
details: {
provider: 'vector-engine',
},
});
});
it('uses api error details.reason when details.message is absent', async () => {
setStoredAccessToken('details-reason-token', { emit: false });
fetchMock.mockResolvedValueOnce(

View File

@@ -95,7 +95,7 @@ describe('babyDrawingClient', () => {
}),
'生成宝贝爱画魔法图片失败',
expect.objectContaining({
timeoutMs: 180000,
timeoutMs: 1_000_000,
}),
);
});

View File

@@ -16,6 +16,7 @@ import { type ApiRetryOptions, requestJson } from '../apiClient';
const STORAGE_KEY = 'genarrative.edutainmentBabyDrawing.localDrawings.v1';
const BABY_LOVE_DRAWING_MAGIC_API =
'/api/creation/edutainment/baby-love-drawing/magic';
const BABY_LOVE_DRAWING_MAGIC_TIMEOUT_MS = 1_000_000;
const BABY_LOVE_DRAWING_MAGIC_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 800,
@@ -116,7 +117,7 @@ export async function createBabyLoveDrawingMagicImage(
'生成宝贝爱画魔法图片失败',
{
retry: BABY_LOVE_DRAWING_MAGIC_RETRY,
timeoutMs: 180000,
timeoutMs: BABY_LOVE_DRAWING_MAGIC_TIMEOUT_MS,
},
);
}

View File

@@ -153,7 +153,7 @@ describe('babyObjectMatchClient', () => {
signal: expect.any(AbortSignal),
}),
);
expect(BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS).toBe(600_000);
expect(BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS).toBe(1_000_000);
expect(response.draft.itemAssets[0]).toMatchObject({
itemId: 'baby-object-item-1',
itemName: '苹果',

View File

@@ -23,7 +23,7 @@ import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode';
const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1';
const BABY_OBJECT_MATCH_ASSET_API =
'/api/creation/edutainment/baby-object-match/assets';
export const BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS = 600_000;
export const BABY_OBJECT_MATCH_ASSET_REQUEST_TIMEOUT_MS = 1_000_000;
const BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY: ApiRetryOptions = {
maxRetries: 0,
};

View File

@@ -21,6 +21,7 @@ import { type ApiRetryOptions, requestJson } from '../apiClient';
const MATCH3D_WORKS_API_BASE = '/api/creation/match3d/works';
const MATCH3D_GALLERY_API_BASE = '/api/runtime/match3d/gallery';
const VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS = 1_000_000;
const MATCH3D_WORKS_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
@@ -152,7 +153,7 @@ export function generateMatch3DCoverImage(
'生成抓大鹅封面图失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
timeoutMs: VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
},
);
}
@@ -174,7 +175,7 @@ export function generateMatch3DBackgroundImage(
'生成抓大鹅背景图失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
timeoutMs: VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
},
);
}
@@ -196,7 +197,7 @@ export function generateMatch3DContainerImage(
'生成抓大鹅容器形象失败',
{
retry: MATCH3D_WORKS_WRITE_RETRY,
timeoutMs: 240_000,
timeoutMs: VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS,
},
);
}

View File

@@ -1,5 +1,5 @@
/**
* 平台首页资料读取入口。
* 直连 RPG profile client避免默认首页首访经过服务桶入口触发额外模块转译
* 复用 RPG profile 聚合出口,避免平台入口和 RPG 入口测试、鉴权包装出现两套读取口径
*/
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient';
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry';

View File

@@ -0,0 +1,94 @@
const PUZZLE_HISTORY_ASSET_FALLBACK_NAME = '历史拼图素材';
function safeDecodePathSegment(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parsePuzzleHistoryTimestamp(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const directDate = new Date(trimmed);
if (!Number.isNaN(directDate.getTime())) {
return directDate;
}
const numericMatch = /^(-?\d+)(?:\.(\d{1,9}))?Z?$/u.exec(trimmed);
if (!numericMatch) {
return null;
}
const wholeSeconds = Number.parseInt(numericMatch[1] ?? '', 10);
if (!Number.isFinite(wholeSeconds)) {
return null;
}
const fractionalMillis = Number.parseInt(
(numericMatch[2] ?? '').padEnd(3, '0').slice(0, 3) || '0',
10,
);
const normalizedMillis = Number.isFinite(fractionalMillis)
? fractionalMillis
: 0;
const useMilliseconds =
Math.abs(wholeSeconds) >= 100_000_000_000 ||
(numericMatch[1] ?? '').length > 10;
const timestampMs = useMilliseconds
? wholeSeconds + (wholeSeconds < 0 ? -normalizedMillis : normalizedMillis)
: wholeSeconds * 1000 +
(wholeSeconds < 0 ? -normalizedMillis : normalizedMillis);
return new Date(timestampMs);
}
export function getPuzzleHistoryAssetDisplayName(
imageSrc: string | null | undefined,
) {
const trimmed = imageSrc?.trim() ?? '';
if (!trimmed) {
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
const pathOnly = trimmed.split(/[?#]/u)[0]?.trim() ?? '';
if (!pathOnly) {
return PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
const fileName = pathOnly.replace(/^\/+/u, '').split('/').filter(Boolean).pop();
const displayName = safeDecodePathSegment(fileName ?? '').trim();
return displayName || PUZZLE_HISTORY_ASSET_FALLBACK_NAME;
}
export function formatPuzzleHistoryAssetCreatedAt(value: string) {
const parsedDate = parsePuzzleHistoryTimestamp(value);
if (!parsedDate) {
return '未知时间';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsedDate);
}
export function getPuzzleHistoryAssetReferenceLabel(
imageSrc: string | null | undefined,
) {
const displayName = getPuzzleHistoryAssetDisplayName(imageSrc);
if (displayName === PUZZLE_HISTORY_ASSET_FALLBACK_NAME) {
return '历史素材';
}
return `历史素材 · ${displayName}`;
}