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:
129
src/components/CustomWorldGenerationView.test.tsx
Normal file
129
src/components/CustomWorldGenerationView.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
145
src/components/common/CreativeImageInputPanel.test.tsx
Normal file
145
src/components/common/CreativeImageInputPanel.test.tsx
Normal 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);
|
||||
});
|
||||
463
src/components/common/CreativeImageInputPanel.tsx
Normal file
463
src/components/common/CreativeImageInputPanel.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
: '',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '图片模型' }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '删除作品' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5196,12 +5196,6 @@ export function RpgEntryHomeView({
|
||||
icon={Star}
|
||||
onClick={openTaskCenterPanel}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="充值"
|
||||
subLabel="泥点/会员"
|
||||
icon={Coins}
|
||||
onClick={openRechargeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
|
||||
239
src/index.css
239
src/index.css
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
77
src/mobileViewportKeyboardFocus.test.ts
Normal file
77
src/mobileViewportKeyboardFocus.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
261
src/mobileViewportKeyboardFocus.ts
Normal file
261
src/mobileViewportKeyboardFocus.ts
Normal 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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('babyDrawingClient', () => {
|
||||
}),
|
||||
'生成宝贝爱画魔法图片失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 180000,
|
||||
timeoutMs: 1_000_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '苹果',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
94
src/services/puzzle-works/puzzleHistoryAsset.ts
Normal file
94
src/services/puzzle-works/puzzleHistoryAsset.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user