1
This commit is contained in:
@@ -1423,6 +1423,11 @@ test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('左右位置')).toBeNull();
|
||||
expect(screen.queryByText('上下位置')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
|
||||
|
||||
|
||||
@@ -47,6 +47,37 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||
}
|
||||
|
||||
function getStepProgressPercentage(step: {
|
||||
completed: number;
|
||||
total: number;
|
||||
status: string;
|
||||
}) {
|
||||
if (step.status === 'completed') {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (step.total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round((step.completed / step.total) * 100)),
|
||||
);
|
||||
}
|
||||
|
||||
function getStepStatusLabel(step: { status: string }) {
|
||||
if (step.status === 'completed') {
|
||||
return '完成';
|
||||
}
|
||||
|
||||
if (step.status === 'active') {
|
||||
return '进行中';
|
||||
}
|
||||
|
||||
return '待处理';
|
||||
}
|
||||
|
||||
function buildFallbackRenderKey(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
@@ -177,30 +208,47 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
{steps.map((step, index) => {
|
||||
const stepProgress = getStepProgressPercentage(step);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'platform-subpanel'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs font-semibold text-zinc-300">
|
||||
{getStepStatusLabel(step)} {stepProgress}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/8">
|
||||
<motion.div
|
||||
className={`h-full rounded-full ${
|
||||
step.status === 'completed'
|
||||
? 'bg-emerald-300'
|
||||
: step.status === 'active'
|
||||
? 'bg-[linear-gradient(90deg,#7dd3fc_0%,#fcd34d_100%)]'
|
||||
: 'bg-white/18'
|
||||
}`}
|
||||
animate={{ width: `${stepProgress}%` }}
|
||||
transition={{ duration: 0.45, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
@@ -27,6 +27,7 @@ type BigFishRuntimeShellProps = {
|
||||
sharePublicWorkCode?: string | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
onBack: () => void;
|
||||
onRestart?: () => void;
|
||||
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
|
||||
@@ -227,6 +228,7 @@ export function BigFishRuntimeShell({
|
||||
sharePublicWorkCode = null,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
onBack,
|
||||
onRestart,
|
||||
onSubmitInput,
|
||||
@@ -360,7 +362,9 @@ export function BigFishRuntimeShell({
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在进入玩法
|
||||
@@ -376,7 +380,9 @@ export function BigFishRuntimeShell({
|
||||
findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative h-full w-full max-w-[430px] touch-none overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(34,211,238,0.2),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(16,185,129,0.18),transparent_26%),linear-gradient(180deg,#082f49,#020617)]"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Bell,
|
||||
Bookmark,
|
||||
ChevronRight,
|
||||
Gamepad2,
|
||||
@@ -346,15 +345,6 @@ export function CreativeAgentHome({
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<RpgEntryBrandLogo className="creative-agent-home__brand" decorative />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenAccount}
|
||||
className="creative-agent-home__topbar-button"
|
||||
aria-label="通知与账户"
|
||||
title="通知与账户"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="creative-agent-home__main">
|
||||
|
||||
@@ -40,6 +40,7 @@ type Match3DRuntimeShellProps = {
|
||||
run: Match3DRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
onOptimisticRunChange: (run: Match3DRunSnapshot) => void;
|
||||
@@ -301,6 +302,7 @@ export function Match3DRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
onBack,
|
||||
onRestart,
|
||||
onOptimisticRunChange,
|
||||
@@ -429,17 +431,21 @@ export function Match3DRuntimeShell({
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`flex ${embedded ? 'h-full min-h-0 w-full' : 'min-h-dvh'} items-center justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
{isBusy ? '载入中' : (error ?? '暂无运行态')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
||||
<main
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100vw',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
@@ -178,9 +178,20 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
expect(uploadCard).not.toBeNull();
|
||||
expect(uploadCard?.closest('.platform-subpanel')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(container.querySelector('.puzzle-creation-form-body')?.className).toContain(
|
||||
'overflow-hidden',
|
||||
);
|
||||
expect(container.querySelector('.puzzle-image-field')?.className).toContain(
|
||||
'flex-1',
|
||||
);
|
||||
|
||||
expect(screen.getByText('拼图画面')).toBeTruthy();
|
||||
expect(screen.getByText('点击上传拼图图片')).toBeTruthy();
|
||||
expect(
|
||||
screen
|
||||
.getByText('若没有合适的图片可以通过填写画面描述生成画面')
|
||||
.closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('点击上传拼图图片').closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.queryByLabelText('拼图创作模板')).toBeNull();
|
||||
expect(
|
||||
@@ -190,15 +201,19 @@ test('puzzle workspace keeps the reference image upload as a primary panel', ()
|
||||
(screen.getByLabelText('画面描述') as HTMLTextAreaElement).placeholder,
|
||||
).toBe('');
|
||||
expect(screen.queryByText(/一只猫在雨夜灯牌下回头/u)).toBeNull();
|
||||
expect(screen.getByLabelText('画面描述').className).toContain(
|
||||
'min-h-[clamp(5rem,15svh,7rem)]',
|
||||
);
|
||||
expect(screen.getByLabelText('画面描述').className).toContain('h-[6rem]');
|
||||
expect(uploadCard?.className).toContain('aspect-square');
|
||||
expect(uploadCard?.className).toContain('h-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: /生成拼图游戏草稿/u })
|
||||
.parentElement?.className,
|
||||
).toContain('justify-center');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在阳光窗台上看着毛线球。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pictureDescription: '一只猫在阳光窗台上看着毛线球。',
|
||||
@@ -221,7 +236,7 @@ test('puzzle upload card stays light in light theme', () => {
|
||||
expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy();
|
||||
const uploadLabel = screen.getByText('点击上传拼图图片');
|
||||
expect(uploadLabel).toBeTruthy();
|
||||
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeNull();
|
||||
expect(uploadLabel.closest('.puzzle-image-upload-card')).toBeTruthy();
|
||||
expect(screen.queryByText('AI重绘')).toBeNull();
|
||||
expect(container.querySelector('.puzzle-image-upload-card')?.className).toContain(
|
||||
'bg-white/90',
|
||||
@@ -245,7 +260,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
@@ -278,7 +293,7 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
|
||||
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -390,7 +405,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
|
||||
expect(screen.queryByText('消耗2光点')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: 'first-level.png',
|
||||
@@ -425,6 +440,51 @@ test('puzzle workspace shows AI redraw switch only after upload', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch', { name: 'AI重绘' })).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'AI重绘' }).closest('.puzzle-image-upload-card'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '移除拼图图片' })).toBeTruthy();
|
||||
expect(screen.queryByText('点击上传拼图图片')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle workspace confirms before removing uploaded image', async () => {
|
||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '移除拼图图片?' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }));
|
||||
expect(screen.queryByRole('dialog', { name: '移除拼图图片?' })).toBeNull();
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除' }));
|
||||
expect(screen.queryByAltText('拼图图片')).toBeNull();
|
||||
expect(screen.queryByRole('switch', { name: 'AI重绘' })).toBeNull();
|
||||
expect(screen.getByText('点击上传拼图图片')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('puzzle workspace opens crop tool for non-square uploads', async () => {
|
||||
@@ -455,6 +515,12 @@ test('puzzle workspace opens crop tool for non-square uploads', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog', { name: '裁剪拼图图片' })).toBeTruthy();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('缩放')).toBeNull();
|
||||
expect(screen.queryByText('横向')).toBeNull();
|
||||
expect(screen.queryByText('纵向')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, X } from 'lucide-react';
|
||||
import { ArrowLeft, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -62,11 +63,79 @@ type PuzzleImageCropState = {
|
||||
imageSize: { width: number; height: number };
|
||||
cropX: number;
|
||||
cropY: number;
|
||||
scale: number;
|
||||
cropSize: number;
|
||||
error: string | null;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
type PuzzleCropDragHandle =
|
||||
| 'move'
|
||||
| 'north'
|
||||
| 'northEast'
|
||||
| 'east'
|
||||
| 'southEast'
|
||||
| 'south'
|
||||
| 'southWest'
|
||||
| 'west'
|
||||
| 'northWest';
|
||||
|
||||
type PuzzleCropDragSnapshot = {
|
||||
pointerId: number;
|
||||
handle: PuzzleCropDragHandle;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
cropRect: { x: number; y: number; size: number };
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
};
|
||||
|
||||
const PUZZLE_CROP_RESIZE_HANDLES: Array<{
|
||||
handle: Exclude<PuzzleCropDragHandle, 'move'>;
|
||||
label: string;
|
||||
className: string;
|
||||
}> = [
|
||||
{
|
||||
handle: 'northWest',
|
||||
label: '拖拽左上角裁剪边界',
|
||||
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
|
||||
},
|
||||
{
|
||||
handle: 'north',
|
||||
label: '拖拽上边裁剪边界',
|
||||
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
|
||||
},
|
||||
{
|
||||
handle: 'northEast',
|
||||
label: '拖拽右上角裁剪边界',
|
||||
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
|
||||
},
|
||||
{
|
||||
handle: 'east',
|
||||
label: '拖拽右边裁剪边界',
|
||||
className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
|
||||
},
|
||||
{
|
||||
handle: 'southEast',
|
||||
label: '拖拽右下角裁剪边界',
|
||||
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
|
||||
},
|
||||
{
|
||||
handle: 'south',
|
||||
label: '拖拽下边裁剪边界',
|
||||
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
|
||||
},
|
||||
{
|
||||
handle: 'southWest',
|
||||
label: '拖拽左下角裁剪边界',
|
||||
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
|
||||
},
|
||||
{
|
||||
handle: 'west',
|
||||
label: '拖拽左边裁剪边界',
|
||||
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
|
||||
},
|
||||
];
|
||||
|
||||
function resolveInitialFormState(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
initialFormPayload: CreatePuzzleAgentSessionRequest | null = null,
|
||||
@@ -125,73 +194,221 @@ function resolveInitialFormState(
|
||||
};
|
||||
}
|
||||
|
||||
function clampPuzzleImageCrop(
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getPuzzleCropSizeBounds(imageSize: { width: number; height: number }) {
|
||||
const maxSize = Math.max(1, Math.min(imageSize.width, imageSize.height));
|
||||
const minSize = Math.min(maxSize, Math.max(48, maxSize * 0.18));
|
||||
|
||||
return { minSize, maxSize };
|
||||
}
|
||||
|
||||
function clampPuzzleImageCropRect(
|
||||
imageSize: { width: number; height: number },
|
||||
scale: number,
|
||||
crop: { x: number; y: number },
|
||||
crop: { x: number; y: number; size: number },
|
||||
) {
|
||||
const cropSize = Math.min(imageSize.width, imageSize.height) / scale;
|
||||
const maxCropX = Math.max(0, imageSize.width - cropSize);
|
||||
const maxCropY = Math.max(0, imageSize.height - cropSize);
|
||||
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
|
||||
const size = clampNumber(crop.size, minSize, maxSize);
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.min(maxCropX, crop.x)),
|
||||
y: Math.max(0, Math.min(maxCropY, crop.y)),
|
||||
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
|
||||
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleCropPreviewStyle(
|
||||
crop: { x: number; y: number; size: number },
|
||||
imageSize: { width: number; height: number },
|
||||
) {
|
||||
return {
|
||||
left: `${(crop.x / imageSize.width) * 100}%`,
|
||||
top: `${(crop.y / imageSize.height) * 100}%`,
|
||||
width: `${(crop.size / imageSize.width) * 100}%`,
|
||||
height: `${(crop.size / imageSize.height) * 100}%`,
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
function resizePuzzleCropRectFromHandle(
|
||||
snapshot: PuzzleCropDragSnapshot,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
imageSize: { width: number; height: number },
|
||||
) {
|
||||
const start = snapshot.cropRect;
|
||||
const startRight = start.x + start.size;
|
||||
const startBottom = start.y + start.size;
|
||||
const startCenterX = start.x + start.size / 2;
|
||||
const startCenterY = start.y + start.size / 2;
|
||||
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
|
||||
const chooseSize = (sizeFromX: number, sizeFromY: number) => {
|
||||
const xDistance = Math.abs(sizeFromX - start.size);
|
||||
const yDistance = Math.abs(sizeFromY - start.size);
|
||||
|
||||
return xDistance >= yDistance ? sizeFromX : sizeFromY;
|
||||
};
|
||||
const clampSize = (size: number, maxByAnchor = maxSize) =>
|
||||
clampNumber(size, minSize, Math.max(minSize, Math.min(maxSize, maxByAnchor)));
|
||||
|
||||
if (snapshot.handle === 'move') {
|
||||
return clampPuzzleImageCropRect(imageSize, {
|
||||
...start,
|
||||
x: start.x + deltaX,
|
||||
y: start.y + deltaY,
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
|
||||
const isEast = snapshot.handle === 'east';
|
||||
const anchorX = isEast ? start.x : startRight;
|
||||
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
|
||||
const maxByCenterY =
|
||||
2 * Math.min(startCenterY, imageSize.height - startCenterY);
|
||||
const size = clampSize(
|
||||
start.size + (isEast ? deltaX : -deltaX),
|
||||
Math.min(maxByAnchorX, maxByCenterY),
|
||||
);
|
||||
|
||||
return clampPuzzleImageCropRect(imageSize, {
|
||||
x: isEast ? anchorX : anchorX - size,
|
||||
y: startCenterY - size / 2,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
|
||||
const isSouth = snapshot.handle === 'south';
|
||||
const anchorY = isSouth ? start.y : startBottom;
|
||||
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
|
||||
const maxByCenterX =
|
||||
2 * Math.min(startCenterX, imageSize.width - startCenterX);
|
||||
const size = clampSize(
|
||||
start.size + (isSouth ? deltaY : -deltaY),
|
||||
Math.min(maxByAnchorY, maxByCenterX),
|
||||
);
|
||||
|
||||
return clampPuzzleImageCropRect(imageSize, {
|
||||
x: startCenterX - size / 2,
|
||||
y: isSouth ? anchorY : anchorY - size,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
const isEast = snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
|
||||
const isSouth = snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
|
||||
const anchorX = isEast ? start.x : startRight;
|
||||
const anchorY = isSouth ? start.y : startBottom;
|
||||
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
|
||||
const maxByAnchorY = isSouth ? imageSize.height - anchorY : anchorY;
|
||||
const sizeFromX = start.size + (isEast ? deltaX : -deltaX);
|
||||
const sizeFromY = start.size + (isSouth ? deltaY : -deltaY);
|
||||
const size = clampSize(
|
||||
chooseSize(sizeFromX, sizeFromY),
|
||||
Math.min(maxByAnchorX, maxByAnchorY),
|
||||
);
|
||||
|
||||
return clampPuzzleImageCropRect(imageSize, {
|
||||
x: isEast ? anchorX : anchorX - size,
|
||||
y: isSouth ? anchorY : anchorY - size,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
function PuzzleImageCropModal({
|
||||
state,
|
||||
onScaleChange,
|
||||
onCropChange,
|
||||
onCropRectChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
state: PuzzleImageCropState;
|
||||
onScaleChange: (value: number) => void;
|
||||
onCropChange: (nextCrop: { x: number; y: number }) => void;
|
||||
onCropRectChange: (nextCrop: { x: number; y: number; size: number }) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
cropX: number;
|
||||
cropY: number;
|
||||
} | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const cropSize = Math.min(state.imageSize.width, state.imageSize.height) /
|
||||
state.scale;
|
||||
const maxCropX = Math.max(0, state.imageSize.width - cropSize);
|
||||
const maxCropY = Math.max(0, state.imageSize.height - cropSize);
|
||||
const backgroundSize = `${(state.imageSize.width / cropSize) * 100}% ${(state.imageSize.height / cropSize) * 100}%`;
|
||||
const backgroundPosition = `${maxCropX > 0 ? (state.cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (state.cropY / maxCropY) * 100 : 50}%`;
|
||||
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const dragStart = dragStartRef.current;
|
||||
const dragSnapshotRef = useRef<PuzzleCropDragSnapshot | null>(null);
|
||||
const [activeDragHandle, setActiveDragHandle] =
|
||||
useState<PuzzleCropDragHandle | null>(null);
|
||||
const cropRect = useMemo(
|
||||
() =>
|
||||
clampPuzzleImageCropRect(state.imageSize, {
|
||||
x: state.cropX,
|
||||
y: state.cropY,
|
||||
size: state.cropSize,
|
||||
}),
|
||||
[state.cropSize, state.cropX, state.cropY, state.imageSize],
|
||||
);
|
||||
const previewStyle = useMemo(
|
||||
() => buildPuzzleCropPreviewStyle(cropRect, state.imageSize),
|
||||
[cropRect, state.imageSize],
|
||||
);
|
||||
const editorPreviewStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
aspectRatio: `${state.imageSize.width} / ${state.imageSize.height}`,
|
||||
width: `min(100%, calc(min(52vh, 22rem) * ${
|
||||
state.imageSize.width / Math.max(1, state.imageSize.height)
|
||||
}))`,
|
||||
}) satisfies CSSProperties,
|
||||
[state.imageSize],
|
||||
);
|
||||
|
||||
const beginCropDrag = (
|
||||
handle: PuzzleCropDragHandle,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (state.isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewRef.current;
|
||||
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = preview.getBoundingClientRect();
|
||||
const sourcePixelsPerPreviewPixel = cropSize / Math.max(1, rect.width);
|
||||
onCropChange({
|
||||
x:
|
||||
dragStart.cropX -
|
||||
(event.clientX - dragStart.clientX) * sourcePixelsPerPreviewPixel,
|
||||
y:
|
||||
dragStart.cropY -
|
||||
(event.clientY - dragStart.clientY) * sourcePixelsPerPreviewPixel,
|
||||
});
|
||||
dragSnapshotRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
handle,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
cropRect,
|
||||
previewWidth: rect.width,
|
||||
previewHeight: rect.height,
|
||||
};
|
||||
setActiveDragHandle(handle);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragStartRef.current?.pointerId === event.pointerId) {
|
||||
dragStartRef.current = null;
|
||||
setIsDragging(false);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
|
||||
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
const snapshot = dragSnapshotRef.current;
|
||||
if (!snapshot || snapshot.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX =
|
||||
((event.clientX - snapshot.clientX) * state.imageSize.width) /
|
||||
Math.max(1, snapshot.previewWidth);
|
||||
const deltaY =
|
||||
((event.clientY - snapshot.clientY) * state.imageSize.height) /
|
||||
Math.max(1, snapshot.previewHeight);
|
||||
onCropRectChange(
|
||||
resizePuzzleCropRectFromHandle(snapshot, deltaX, deltaY, state.imageSize),
|
||||
);
|
||||
};
|
||||
|
||||
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragSnapshotRef.current = null;
|
||||
setActiveDragHandle(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -218,85 +435,53 @@ function PuzzleImageCropModal({
|
||||
<div className="px-5 py-5">
|
||||
<div
|
||||
ref={previewRef}
|
||||
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.2rem] border border-white/12 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: `url("${state.source}")`,
|
||||
backgroundSize,
|
||||
backgroundPosition,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
role="img"
|
||||
aria-label="拼图图片裁剪预览"
|
||||
onPointerDown={(event) => {
|
||||
dragStartRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
cropX: state.cropX,
|
||||
cropY: state.cropY,
|
||||
};
|
||||
setIsDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={updateDragCrop}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
/>
|
||||
<div className="mt-5 space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
缩放
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.01"
|
||||
value={state.scale}
|
||||
onChange={(event) => onScaleChange(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
横向
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxCropX}
|
||||
step="1"
|
||||
value={Math.min(state.cropX, maxCropX)}
|
||||
onChange={(event) =>
|
||||
onCropChange({
|
||||
x: Number(event.target.value),
|
||||
y: state.cropY,
|
||||
})
|
||||
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
|
||||
style={editorPreviewStyle}
|
||||
aria-label="拼图图片裁剪操作区"
|
||||
>
|
||||
<img
|
||||
src={state.source}
|
||||
alt="拼图图片裁剪预览"
|
||||
draggable={false}
|
||||
className="h-full w-full object-fill"
|
||||
/>
|
||||
<div
|
||||
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
|
||||
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
|
||||
}`}
|
||||
style={previewStyle}
|
||||
onPointerDown={(event) => beginCropDrag('move', event)}
|
||||
onPointerMove={updateCropDrag}
|
||||
onPointerUp={stopCropDrag}
|
||||
onPointerCancel={stopCropDrag}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute border border-white/70"
|
||||
style={previewStyle}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
|
||||
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
|
||||
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
|
||||
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute" style={previewStyle}>
|
||||
{PUZZLE_CROP_RESIZE_HANDLES.map((handleConfig) => (
|
||||
<button
|
||||
key={handleConfig.handle}
|
||||
type="button"
|
||||
aria-label={handleConfig.label}
|
||||
disabled={state.isSaving}
|
||||
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 ${handleConfig.className}`}
|
||||
onPointerDown={(event) =>
|
||||
beginCropDrag(handleConfig.handle, event)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
纵向
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxCropY}
|
||||
step="1"
|
||||
value={Math.min(state.cropY, maxCropY)}
|
||||
onChange={(event) =>
|
||||
onCropChange({
|
||||
x: state.cropX,
|
||||
y: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
onPointerMove={updateCropDrag}
|
||||
onPointerUp={stopCropDrag}
|
||||
onPointerCancel={stopCropDrag}
|
||||
>
|
||||
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{state.error ? (
|
||||
@@ -350,6 +535,8 @@ export function PuzzleAgentWorkspace({
|
||||
null,
|
||||
);
|
||||
const [cropState, setCropState] = useState<PuzzleImageCropState | null>(null);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -378,6 +565,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
setCropState(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
@@ -466,7 +654,7 @@ export function PuzzleAgentWorkspace({
|
||||
},
|
||||
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
|
||||
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
|
||||
scale: 1,
|
||||
cropSize,
|
||||
error: null,
|
||||
isSaving: false,
|
||||
});
|
||||
@@ -480,6 +668,7 @@ export function PuzzleAgentWorkspace({
|
||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
} catch (uploadError) {
|
||||
setReferenceImageError(
|
||||
uploadError instanceof Error
|
||||
@@ -489,39 +678,17 @@ export function PuzzleAgentWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const updateCropState = (nextCrop: { x: number; y: number }) => {
|
||||
const updateCropState = (nextCrop: { x: number; y: number; size: number }) => {
|
||||
setCropState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const clamped = clampPuzzleImageCrop(
|
||||
current.imageSize,
|
||||
current.scale,
|
||||
nextCrop,
|
||||
);
|
||||
const clamped = clampPuzzleImageCropRect(current.imageSize, nextCrop);
|
||||
return {
|
||||
...current,
|
||||
cropX: clamped.x,
|
||||
cropY: clamped.y,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateCropScale = (nextScale: number) => {
|
||||
setCropState((current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
const scale = Math.max(1, Math.min(3, nextScale || 1));
|
||||
const clamped = clampPuzzleImageCrop(current.imageSize, scale, {
|
||||
x: current.cropX,
|
||||
y: current.cropY,
|
||||
});
|
||||
return {
|
||||
...current,
|
||||
scale,
|
||||
cropX: clamped.x,
|
||||
cropY: clamped.y,
|
||||
cropSize: clamped.size,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -539,16 +706,11 @@ export function PuzzleAgentWorkspace({
|
||||
});
|
||||
|
||||
try {
|
||||
const cropSize =
|
||||
Math.min(
|
||||
currentCropState.imageSize.width,
|
||||
currentCropState.imageSize.height,
|
||||
) / currentCropState.scale;
|
||||
const dataUrl = await cropPuzzleReferenceImageDataUrl({
|
||||
source: currentCropState.source,
|
||||
cropX: currentCropState.cropX,
|
||||
cropY: currentCropState.cropY,
|
||||
cropSize,
|
||||
cropSize: currentCropState.cropSize,
|
||||
});
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
@@ -557,6 +719,7 @@ export function PuzzleAgentWorkspace({
|
||||
}));
|
||||
setCropState(null);
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
} catch (cropError) {
|
||||
setCropState({
|
||||
...currentCropState,
|
||||
@@ -600,14 +763,24 @@ export function PuzzleAgentWorkspace({
|
||||
candidateCount: 1,
|
||||
});
|
||||
};
|
||||
const confirmRemoveReferenceImage = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
aiRedraw: true,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
setIsRemoveImageConfirmOpen(false);
|
||||
};
|
||||
const pictureDescriptionLabel = formState.referenceImageSrc
|
||||
? '画面AI重绘要求(提示词)'
|
||||
: '画面描述';
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<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">
|
||||
{showBackButton ? (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
@@ -622,11 +795,11 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<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-5">
|
||||
<div className="mb-3 shrink-0 sm:mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-4xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
<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">
|
||||
@@ -636,101 +809,123 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-visible">
|
||||
<section className="puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
|
||||
<div
|
||||
className={`grid gap-3 sm:gap-4 ${
|
||||
className={`puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
|
||||
formState.aiRedraw
|
||||
? 'lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
|
||||
: 'lg:grid-cols-1'
|
||||
? '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={`min-w-0 ${isBusy ? 'opacity-55' : ''}`}>
|
||||
<div className="mb-2 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
<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-upload-card relative aspect-square w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_16px_34px_rgba(222,82,124,0.12)] transition">
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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-16 w-16 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-7 w-7 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" />
|
||||
{formState.referenceImageSrc ? (
|
||||
<label className="absolute right-3 top-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'
|
||||
}`}
|
||||
/>
|
||||
<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>
|
||||
) : null}
|
||||
{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" />
|
||||
{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 right-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-3 left-1/2 z-10 inline-flex min-h-10 -translate-x-1/2 items-center justify-center whitespace-nowrap rounded-full border border-white/80 bg-white/94 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
</label>
|
||||
)}
|
||||
{formState.referenceImageSrc ? null : (
|
||||
<div className="pointer-events-none absolute bottom-16 left-4 right-4 z-10 text-center text-[11px] font-semibold leading-4 text-[var(--platform-text-soft)]">
|
||||
若没有合适的图片可以通过填写画面描述生成画面
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="puzzle-image-upload-input"
|
||||
className={`mt-2 block text-center text-sm font-black text-[var(--platform-text-strong)] transition hover:text-[#ff4056] ${isBusy ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
点击上传拼图图片
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formState.aiRedraw ? (
|
||||
<label className="block min-h-0">
|
||||
<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>
|
||||
@@ -746,7 +941,7 @@ export function PuzzleAgentWorkspace({
|
||||
pictureDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="min-h-[clamp(5rem,15svh,7rem)] w-full resize-none rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[8.5rem] lg:min-h-[10.5rem]"
|
||||
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
|
||||
@@ -764,33 +959,7 @@ export function PuzzleAgentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{formState.referenceImageSrc ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
aiRedraw: true,
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-xs"
|
||||
aria-label="移除拼图图片"
|
||||
title="移除拼图图片"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
移除拼图图片
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 shrink-0 space-y-3">
|
||||
{referenceImageError ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{referenceImageError}
|
||||
@@ -805,17 +974,17 @@ export function PuzzleAgentWorkspace({
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
|
||||
<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 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
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 items-center gap-2">
|
||||
<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>
|
||||
<span>生成拼图游戏草稿</span>
|
||||
{formState.aiRedraw ? (
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗2光点
|
||||
@@ -827,14 +996,49 @@ export function PuzzleAgentWorkspace({
|
||||
{cropState ? (
|
||||
<PuzzleImageCropModal
|
||||
state={cropState}
|
||||
onScaleChange={updateCropScale}
|
||||
onCropChange={updateCropState}
|
||||
onCropRectChange={updateCropState}
|
||||
onClose={() => setCropState(null)}
|
||||
onSubmit={() => {
|
||||
void applyCropState();
|
||||
}}
|
||||
/>
|
||||
) : 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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -864,7 +864,7 @@ function PuzzleLevelDetailDialog({
|
||||
{referenceImageSrc ? (
|
||||
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
|
||||
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<img
|
||||
<ResolvedAssetImage
|
||||
src={referenceImageSrc}
|
||||
alt="拼图参考图"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -529,7 +529,19 @@ test('合并块按实际拼块外轮廓描边', () => {
|
||||
expect(outlineStroke).toBeTruthy();
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-merged-group-outline="true"]')
|
||||
?.getAttribute('fill'),
|
||||
).toBe('transparent');
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
for (const outlinedPiece of outlinedPieces) {
|
||||
const outlinedPieceElement = outlinedPiece as HTMLElement;
|
||||
expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
|
||||
expect(
|
||||
outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
|
||||
).toBeNull();
|
||||
}
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
@@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
hideBackButton?: boolean;
|
||||
embedded?: boolean;
|
||||
onBack: () => void;
|
||||
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
@@ -308,6 +309,7 @@ export function PuzzleRuntimeShell({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
hideBackButton = false,
|
||||
embedded = false,
|
||||
onBack,
|
||||
onRemodelWork,
|
||||
onSwapPieces,
|
||||
@@ -787,7 +789,9 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在进入拼图关卡
|
||||
@@ -1079,7 +1083,9 @@ export function PuzzleRuntimeShell({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
|
||||
{currentLevel.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -1346,7 +1352,7 @@ export function PuzzleRuntimeShell({
|
||||
<path
|
||||
d={outlinePath}
|
||||
data-merged-group-outline="true"
|
||||
fill="rgba(52, 211, 153, 0.08)"
|
||||
fill="transparent"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
@@ -1380,7 +1386,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)]"
|
||||
className="pointer-events-auto relative touch-none overflow-hidden shadow-[0_12px_30px_rgba(15,23,42,0.16)]"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
@@ -1422,7 +1428,6 @@ export function PuzzleRuntimeShell({
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { ChangeEvent, CSSProperties, PointerEvent } from 'react';
|
||||
import {
|
||||
Children,
|
||||
type ReactNode,
|
||||
@@ -954,18 +953,123 @@ function loadImageDimensionsFromDataUrl(source: string) {
|
||||
});
|
||||
}
|
||||
|
||||
const COVER_CROP_RATIO = 16 / 9;
|
||||
|
||||
type CoverCropDragHandle =
|
||||
| 'move'
|
||||
| 'north'
|
||||
| 'northEast'
|
||||
| 'east'
|
||||
| 'southEast'
|
||||
| 'south'
|
||||
| 'southWest'
|
||||
| 'west'
|
||||
| 'northWest';
|
||||
|
||||
type CoverCropDragSnapshot = {
|
||||
pointerId: number;
|
||||
handle: CoverCropDragHandle;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
cropRect: CustomWorldCoverCropRect;
|
||||
previewWidth: number;
|
||||
previewHeight: number;
|
||||
};
|
||||
|
||||
const COVER_CROP_RESIZE_HANDLES: Array<{
|
||||
handle: Exclude<CoverCropDragHandle, 'move'>;
|
||||
label: string;
|
||||
className: string;
|
||||
dotClassName: string;
|
||||
}> = [
|
||||
{
|
||||
handle: 'northWest',
|
||||
label: '拖拽左上角裁剪边界',
|
||||
className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'north',
|
||||
label: '拖拽上边裁剪边界',
|
||||
className: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 cursor-ns-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'northEast',
|
||||
label: '拖拽右上角裁剪边界',
|
||||
className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'east',
|
||||
label: '拖拽右边裁剪边界',
|
||||
className: 'right-0 top-1/2 -translate-y-1/2 translate-x-1/2 cursor-ew-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'southEast',
|
||||
label: '拖拽右下角裁剪边界',
|
||||
className: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'south',
|
||||
label: '拖拽下边裁剪边界',
|
||||
className: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 cursor-ns-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'southWest',
|
||||
label: '拖拽左下角裁剪边界',
|
||||
className: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
{
|
||||
handle: 'west',
|
||||
label: '拖拽左边裁剪边界',
|
||||
className: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize',
|
||||
dotClassName: 'left-1/2 top-1/2',
|
||||
},
|
||||
];
|
||||
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getCoverCropSizeBounds(imageSize: { width: number; height: number }) {
|
||||
const maxWidth = Math.max(
|
||||
1,
|
||||
Math.min(imageSize.width, imageSize.height * COVER_CROP_RATIO),
|
||||
);
|
||||
const minWidth = Math.min(maxWidth, Math.max(48, maxWidth * 0.16));
|
||||
|
||||
return { minWidth, maxWidth };
|
||||
}
|
||||
|
||||
function normalizeCoverCropRect(
|
||||
cropRect: CustomWorldCoverCropRect,
|
||||
imageSize: { width: number; height: number },
|
||||
): CustomWorldCoverCropRect {
|
||||
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
|
||||
const width = clampNumber(cropRect.width, minWidth, maxWidth);
|
||||
const height = width / COVER_CROP_RATIO;
|
||||
const x = clampNumber(cropRect.x, 0, Math.max(0, imageSize.width - width));
|
||||
const y = clampNumber(cropRect.y, 0, Math.max(0, imageSize.height - height));
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function buildCenteredCoverCropRect(
|
||||
width: number,
|
||||
height: number,
|
||||
): CustomWorldCoverCropRect {
|
||||
const targetRatio = 16 / 9;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return { x: 0, y: 0, width: 1, height: 1 };
|
||||
}
|
||||
|
||||
if (width / height >= targetRatio) {
|
||||
if (width / height >= COVER_CROP_RATIO) {
|
||||
const cropHeight = height;
|
||||
const cropWidth = cropHeight * targetRatio;
|
||||
const cropWidth = cropHeight * COVER_CROP_RATIO;
|
||||
return {
|
||||
x: (width - cropWidth) / 2,
|
||||
y: 0,
|
||||
@@ -975,7 +1079,7 @@ function buildCenteredCoverCropRect(
|
||||
}
|
||||
|
||||
const cropWidth = width;
|
||||
const cropHeight = cropWidth / targetRatio;
|
||||
const cropHeight = cropWidth / COVER_CROP_RATIO;
|
||||
return {
|
||||
x: 0,
|
||||
y: (height - cropHeight) / 2,
|
||||
@@ -984,16 +1088,111 @@ function buildCenteredCoverCropRect(
|
||||
};
|
||||
}
|
||||
|
||||
function clampCoverCropRect(
|
||||
cropRect: CustomWorldCoverCropRect,
|
||||
function resizeCoverCropRectFromHandle(
|
||||
snapshot: CoverCropDragSnapshot,
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
imageSize: { width: number; height: number },
|
||||
) {
|
||||
const width = Math.max(1, Math.min(imageSize.width, cropRect.width));
|
||||
const height = Math.max(1, Math.min(imageSize.height, cropRect.height));
|
||||
const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x));
|
||||
const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y));
|
||||
): CustomWorldCoverCropRect {
|
||||
const start = snapshot.cropRect;
|
||||
const startRight = start.x + start.width;
|
||||
const startBottom = start.y + start.height;
|
||||
const startCenterX = start.x + start.width / 2;
|
||||
const startCenterY = start.y + start.height / 2;
|
||||
const { minWidth, maxWidth } = getCoverCropSizeBounds(imageSize);
|
||||
const chooseWidth = (widthFromX: number, widthFromY: number) => {
|
||||
const xDistance = Math.abs(widthFromX - start.width);
|
||||
const yDistance = Math.abs(widthFromY - start.width);
|
||||
|
||||
return { x, y, width, height };
|
||||
return xDistance >= yDistance ? widthFromX : widthFromY;
|
||||
};
|
||||
const clampWidth = (width: number, maxByAnchor = maxWidth) =>
|
||||
clampNumber(width, minWidth, Math.max(minWidth, Math.min(maxWidth, maxByAnchor)));
|
||||
|
||||
if (snapshot.handle === 'move') {
|
||||
return normalizeCoverCropRect(
|
||||
{
|
||||
...start,
|
||||
x: start.x + deltaX,
|
||||
y: start.y + deltaY,
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.handle === 'east' || snapshot.handle === 'west') {
|
||||
const isEast = snapshot.handle === 'east';
|
||||
const anchorX = isEast ? start.x : startRight;
|
||||
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
|
||||
const maxByCenterY =
|
||||
2 * Math.min(startCenterY, imageSize.height - startCenterY) *
|
||||
COVER_CROP_RATIO;
|
||||
const width = clampWidth(
|
||||
start.width + (isEast ? deltaX : -deltaX),
|
||||
Math.min(maxByAnchorX, maxByCenterY),
|
||||
);
|
||||
const height = width / COVER_CROP_RATIO;
|
||||
|
||||
return normalizeCoverCropRect(
|
||||
{
|
||||
x: isEast ? anchorX : anchorX - width,
|
||||
y: startCenterY - height / 2,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.handle === 'north' || snapshot.handle === 'south') {
|
||||
const isSouth = snapshot.handle === 'south';
|
||||
const anchorY = isSouth ? start.y : startBottom;
|
||||
const maxByAnchorY =
|
||||
(isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
|
||||
const maxByCenterX =
|
||||
2 * Math.min(startCenterX, imageSize.width - startCenterX);
|
||||
const width = clampWidth(
|
||||
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO,
|
||||
Math.min(maxByAnchorY, maxByCenterX),
|
||||
);
|
||||
const height = width / COVER_CROP_RATIO;
|
||||
|
||||
return normalizeCoverCropRect(
|
||||
{
|
||||
x: startCenterX - width / 2,
|
||||
y: isSouth ? anchorY : anchorY - height,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
}
|
||||
|
||||
const isEast = snapshot.handle === 'northEast' || snapshot.handle === 'southEast';
|
||||
const isSouth = snapshot.handle === 'southEast' || snapshot.handle === 'southWest';
|
||||
const anchorX = isEast ? start.x : startRight;
|
||||
const anchorY = isSouth ? start.y : startBottom;
|
||||
const maxByAnchorX = isEast ? imageSize.width - anchorX : anchorX;
|
||||
const maxByAnchorY =
|
||||
(isSouth ? imageSize.height - anchorY : anchorY) * COVER_CROP_RATIO;
|
||||
const widthFromX = start.width + (isEast ? deltaX : -deltaX);
|
||||
const widthFromY =
|
||||
(start.height + (isSouth ? deltaY : -deltaY)) * COVER_CROP_RATIO;
|
||||
const width = clampWidth(
|
||||
chooseWidth(widthFromX, widthFromY),
|
||||
Math.min(maxByAnchorX, maxByAnchorY),
|
||||
);
|
||||
const height = width / COVER_CROP_RATIO;
|
||||
|
||||
return normalizeCoverCropRect(
|
||||
{
|
||||
x: isEast ? anchorX : anchorX - width,
|
||||
y: isSouth ? anchorY : anchorY - height,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
imageSize,
|
||||
);
|
||||
}
|
||||
|
||||
function buildCoverCropPreviewStyle(
|
||||
@@ -3316,51 +3515,116 @@ function buildGeneratedCoverProfile(
|
||||
function CoverUploadCropModal({
|
||||
imageDataUrl,
|
||||
imageSize,
|
||||
worldName,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
imageDataUrl: string;
|
||||
imageSize: { width: number; height: number };
|
||||
worldName: string;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: (cropRect: CustomWorldCoverCropRect) => void;
|
||||
}) {
|
||||
const [zoomPercent, setZoomPercent] = useState(100);
|
||||
const baseCropRect = useMemo(
|
||||
() => buildCenteredCoverCropRect(imageSize.width, imageSize.height),
|
||||
[imageSize],
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragSnapshotRef = useRef<CoverCropDragSnapshot | null>(null);
|
||||
const [activeDragHandle, setActiveDragHandle] =
|
||||
useState<CoverCropDragHandle | null>(null);
|
||||
const [cropRect, setCropRect] = useState(() =>
|
||||
normalizeCoverCropRect(
|
||||
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
|
||||
imageSize,
|
||||
),
|
||||
);
|
||||
const [offsetX, setOffsetX] = useState(0);
|
||||
const [offsetY, setOffsetY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setZoomPercent(100);
|
||||
setOffsetX(0);
|
||||
setOffsetY(0);
|
||||
}, [imageDataUrl]);
|
||||
|
||||
const cropRect = useMemo(() => {
|
||||
const scale = Math.max(1, zoomPercent / 100);
|
||||
const nextCropRect = {
|
||||
width: baseCropRect.width / scale,
|
||||
height: baseCropRect.height / scale,
|
||||
x: baseCropRect.x + offsetX,
|
||||
y: baseCropRect.y + offsetY,
|
||||
};
|
||||
|
||||
return clampCoverCropRect(nextCropRect, imageSize);
|
||||
}, [baseCropRect, imageSize, offsetX, offsetY, zoomPercent]);
|
||||
setActiveDragHandle(null);
|
||||
dragSnapshotRef.current = null;
|
||||
setCropRect(
|
||||
normalizeCoverCropRect(
|
||||
buildCenteredCoverCropRect(imageSize.width, imageSize.height),
|
||||
imageSize,
|
||||
),
|
||||
);
|
||||
}, [imageDataUrl, imageSize]);
|
||||
|
||||
const previewStyle = useMemo(
|
||||
() => buildCoverCropPreviewStyle(cropRect, imageSize),
|
||||
[cropRect, imageSize],
|
||||
);
|
||||
const editorPreviewStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
aspectRatio: `${imageSize.width} / ${imageSize.height}`,
|
||||
width: `min(100%, calc(min(58vh, 34rem) * ${
|
||||
imageSize.width / Math.max(1, imageSize.height)
|
||||
}))`,
|
||||
}) satisfies CSSProperties,
|
||||
[imageSize],
|
||||
);
|
||||
const outputPreviewStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
left: `${-(cropRect.x / cropRect.width) * 100}%`,
|
||||
top: `${-(cropRect.y / cropRect.height) * 100}%`,
|
||||
width: `${(imageSize.width / cropRect.width) * 100}%`,
|
||||
height: `${(imageSize.height / cropRect.height) * 100}%`,
|
||||
}) satisfies CSSProperties,
|
||||
[cropRect, imageSize],
|
||||
);
|
||||
|
||||
const maxOffsetX = Math.max(0, imageSize.width - cropRect.width);
|
||||
const maxOffsetY = Math.max(0, imageSize.height - cropRect.height);
|
||||
const beginCropDrag = (
|
||||
handle: CoverCropDragHandle,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = previewRef.current;
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = preview.getBoundingClientRect();
|
||||
dragSnapshotRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
handle,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
cropRect,
|
||||
previewWidth: rect.width,
|
||||
previewHeight: rect.height,
|
||||
};
|
||||
setActiveDragHandle(handle);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const updateCropDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
const snapshot = dragSnapshotRef.current;
|
||||
if (!snapshot || snapshot.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX =
|
||||
((event.clientX - snapshot.clientX) * imageSize.width) /
|
||||
Math.max(1, snapshot.previewWidth);
|
||||
const deltaY =
|
||||
((event.clientY - snapshot.clientY) * imageSize.height) /
|
||||
Math.max(1, snapshot.previewHeight);
|
||||
setCropRect(resizeCoverCropRectFromHandle(snapshot, deltaX, deltaY, imageSize));
|
||||
};
|
||||
|
||||
const stopCropDrag = (event: PointerEvent<HTMLElement>) => {
|
||||
if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragSnapshotRef.current = null;
|
||||
setActiveDragHandle(null);
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
@@ -3371,77 +3635,75 @@ function CoverUploadCropModal({
|
||||
disableClose={isSubmitting}
|
||||
>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.05fr)_20rem]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
|
||||
<ImagePreview
|
||||
src={imageDataUrl}
|
||||
alt="上传封面裁剪预览"
|
||||
fallbackLabel={worldName.slice(0, 4) || '封面'}
|
||||
tone="landscape"
|
||||
overlayInteractive
|
||||
previewOverlay={
|
||||
<>
|
||||
<div className="absolute inset-0 bg-black/45" />
|
||||
<div
|
||||
className="absolute border border-sky-300/90 bg-white/8 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
|
||||
style={previewStyle}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="缩放">
|
||||
<input
|
||||
type="range"
|
||||
min={100}
|
||||
max={220}
|
||||
step={1}
|
||||
value={zoomPercent}
|
||||
onChange={(event) => setZoomPercent(Number(event.target.value))}
|
||||
disabled={isSubmitting}
|
||||
className="w-full accent-sky-400"
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 p-2 sm:p-3">
|
||||
<div
|
||||
ref={previewRef}
|
||||
aria-label="封面裁剪操作区"
|
||||
className="relative mx-auto overflow-hidden rounded-xl border border-white/10 bg-black/40 select-none touch-none"
|
||||
style={editorPreviewStyle}
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={imageDataUrl}
|
||||
alt="上传封面裁剪预览"
|
||||
className="h-full w-full object-fill"
|
||||
draggable={false}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="左右位置">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, Math.floor(maxOffsetX))}
|
||||
step={1}
|
||||
value={Math.max(0, Math.floor(offsetX + baseCropRect.x))}
|
||||
onChange={(event) =>
|
||||
setOffsetX(Number(event.target.value) - baseCropRect.x)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className="w-full accent-sky-400"
|
||||
<div
|
||||
className={`absolute border-2 border-sky-200/95 shadow-[0_0_0_9999px_rgba(0,0,0,0.38)] outline outline-1 outline-black/35 ${
|
||||
activeDragHandle === 'move' ? 'cursor-grabbing' : 'cursor-grab'
|
||||
}`}
|
||||
style={previewStyle}
|
||||
onPointerDown={(event) => beginCropDrag('move', event)}
|
||||
onPointerMove={updateCropDrag}
|
||||
onPointerUp={stopCropDrag}
|
||||
onPointerCancel={stopCropDrag}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="上下位置">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, Math.floor(maxOffsetY))}
|
||||
step={1}
|
||||
value={Math.max(0, Math.floor(offsetY + baseCropRect.y))}
|
||||
onChange={(event) =>
|
||||
setOffsetY(Number(event.target.value) - baseCropRect.y)
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
className="w-full accent-sky-400"
|
||||
/>
|
||||
</Field>
|
||||
<div
|
||||
className="pointer-events-none absolute border border-white/70"
|
||||
style={previewStyle}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/3 border-t border-white/35" />
|
||||
<div className="absolute inset-x-0 top-2/3 border-t border-white/35" />
|
||||
<div className="absolute inset-y-0 left-1/3 border-l border-white/35" />
|
||||
<div className="absolute inset-y-0 left-2/3 border-l border-white/35" />
|
||||
</div>
|
||||
<div className="pointer-events-none absolute" style={previewStyle}>
|
||||
{COVER_CROP_RESIZE_HANDLES.map((handleConfig) => (
|
||||
<button
|
||||
key={handleConfig.handle}
|
||||
type="button"
|
||||
aria-label={handleConfig.label}
|
||||
disabled={isSubmitting}
|
||||
className={`pointer-events-auto absolute z-10 h-10 w-10 rounded-full border border-white/15 bg-black/5 p-0 disabled:cursor-not-allowed disabled:opacity-45 sm:h-9 sm:w-9 ${handleConfig.className}`}
|
||||
onPointerDown={(event) =>
|
||||
beginCropDrag(handleConfig.handle, event)
|
||||
}
|
||||
onPointerMove={updateCropDrag}
|
||||
onPointerUp={stopCropDrag}
|
||||
onPointerCancel={stopCropDrag}
|
||||
>
|
||||
<span
|
||||
className={`absolute h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-sky-300 shadow-[0_0_0_3px_rgba(2,132,199,0.32)] ${handleConfig.dotClassName}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-6 text-zinc-200">
|
||||
成品会固定保存为 16:9,并由后端统一压缩到 1600 × 900。
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-xs leading-6 text-zinc-400">
|
||||
当前裁剪区域:
|
||||
<br />
|
||||
{`x ${Math.round(cropRect.x)} / y ${Math.round(cropRect.y)} / w ${Math.round(cropRect.width)} / h ${Math.round(cropRect.height)}`}
|
||||
<div className="overflow-hidden rounded-2xl border border-white/8 bg-black/20 p-2">
|
||||
<div className="relative aspect-[16/9] overflow-hidden rounded-xl bg-black/30">
|
||||
<ResolvedAssetImage
|
||||
src={imageDataUrl}
|
||||
alt="上传封面裁剪结果预览"
|
||||
className="absolute max-w-none object-fill"
|
||||
draggable={false}
|
||||
style={outputPreviewStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<ActionButton
|
||||
@@ -3901,7 +4163,6 @@ export function WorldCoverEditor({
|
||||
<CoverUploadCropModal
|
||||
imageDataUrl={pendingUploadImageDataUrl}
|
||||
imageSize={pendingUploadImageSize}
|
||||
worldName={profile.name}
|
||||
isSubmitting={isUploading}
|
||||
onCancel={() => {
|
||||
if (isUploading) {
|
||||
|
||||
@@ -74,6 +74,10 @@ import {
|
||||
listPuzzleGallery,
|
||||
remixPuzzleGalleryWork,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
generatePuzzleOnboardingWork,
|
||||
savePuzzleOnboardingWork,
|
||||
} from '../../services/puzzle-onboarding';
|
||||
import {
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
@@ -161,7 +165,7 @@ async function clickFirstAsyncButtonByName(
|
||||
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
|
||||
expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy();
|
||||
@@ -390,6 +394,11 @@ vi.mock('../../services/puzzle-runtime/puzzleLocalRuntime', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/puzzle-onboarding', () => ({
|
||||
generatePuzzleOnboardingWork: vi.fn(),
|
||||
savePuzzleOnboardingWork: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-agent', () => ({
|
||||
createPuzzleAgentSession: vi.fn(),
|
||||
executePuzzleAgentAction: vi.fn(),
|
||||
@@ -2080,6 +2089,107 @@ beforeEach(() => {
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [],
|
||||
});
|
||||
vi.mocked(generatePuzzleOnboardingWork).mockResolvedValue({
|
||||
item: {
|
||||
workId: 'onboarding-work-1',
|
||||
profileId: 'onboarding-profile-1',
|
||||
ownerUserId: 'onboarding-guest',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '百梦主',
|
||||
workTitle: '梦境拼图',
|
||||
workDescription: '我想飞上天',
|
||||
levelName: '云上飞行',
|
||||
summary: '我想飞上天',
|
||||
themeTags: ['新手引导', '拼图'],
|
||||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||||
coverAssetId: 'onboarding-asset-1',
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [],
|
||||
},
|
||||
level: {
|
||||
levelId: 'onboarding-level-1',
|
||||
levelName: '云上飞行',
|
||||
pictureDescription: '我想飞上天',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'onboarding-candidate-1',
|
||||
imageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||||
assetId: 'onboarding-asset-1',
|
||||
prompt: '我想飞上天',
|
||||
actualPrompt: '我想飞上天',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'onboarding-candidate-1',
|
||||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||||
coverAssetId: 'onboarding-asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
});
|
||||
vi.mocked(savePuzzleOnboardingWork).mockResolvedValue({
|
||||
item: {
|
||||
workId: 'onboarding-work-saved',
|
||||
profileId: 'onboarding-profile-saved',
|
||||
ownerUserId: mockAuthUser.id,
|
||||
sourceSessionId: 'puzzle-session-onboarding',
|
||||
authorDisplayName: mockAuthUser.displayName,
|
||||
workTitle: '梦境拼图',
|
||||
workDescription: '我想飞上天',
|
||||
levelName: '云上飞行',
|
||||
summary: '我想飞上天',
|
||||
themeTags: ['新手引导', '拼图'],
|
||||
coverImageSrc: 'data:image/svg+xml;utf8,onboarding',
|
||||
coverAssetId: 'onboarding-asset-1',
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-05T12:00:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
levels: [],
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'theme_promise',
|
||||
label: '主题承诺',
|
||||
value: '新手引导',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visual_subject',
|
||||
label: '视觉主体',
|
||||
value: '云上飞行',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visual_mood',
|
||||
label: '视觉气质',
|
||||
value: '明亮',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'composition_hooks',
|
||||
label: '构图钩子',
|
||||
value: '天空',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tags_and_forbidden',
|
||||
label: '标签与禁区',
|
||||
value: '拼图',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(remixPuzzleGalleryWork).mockRejectedValue(
|
||||
new Error('未启用拼图 remix'),
|
||||
);
|
||||
@@ -3262,7 +3372,7 @@ test('puzzle draft result back button returns to creation hub', async () => {
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('雨夜里有一只会发光的猫站在遗迹台阶上。')).toBeTruthy();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
@@ -3312,6 +3422,82 @@ test('published puzzle work card restores its source session for editing', async
|
||||
expect(screen.getByDisplayValue('雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding can be skipped from top right', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: () => {},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('待定待定待定')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '跳过' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('待定待定待定')).toBeNull();
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
),
|
||||
).toBe('1');
|
||||
expect(generatePuzzleOnboardingWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('first launch puzzle onboarding falls back to local run when generate route is missing', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.localStorage.removeItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
);
|
||||
vi.mocked(generatePuzzleOnboardingWork).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '资源不存在',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper
|
||||
authValue={createAuthValue({
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: () => {},
|
||||
requireAuth: () => {},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(
|
||||
await screen.findByPlaceholderText('把你的梦讲给我听吧'),
|
||||
'我想飞上天',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('puzzle-board', undefined, { timeout: 3000 }),
|
||||
).toBeTruthy();
|
||||
expect(generatePuzzleOnboardingWork).toHaveBeenCalledWith({
|
||||
promptText: '我想飞上天',
|
||||
});
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).not.toHaveBeenCalled();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||
),
|
||||
).toBe('1');
|
||||
});
|
||||
|
||||
test('formal puzzle runtime uses frontend move merge logic and backend leaderboard next level', async () => {
|
||||
const user = userEvent.setup();
|
||||
const clearedFirstLevel = buildClearedPuzzleRun({
|
||||
@@ -4717,7 +4903,7 @@ test('agent draft result back button returns to creation hub without syncing res
|
||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('10分钟创作一个精品互动玩法')).toBeTruthy();
|
||||
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -5041,16 +5227,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(
|
||||
await screen.findByText('10分钟创作一个精品互动玩法'),
|
||||
await screen.findByRole('tablist', { name: '选择模板' }),
|
||||
).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText(
|
||||
'10分钟创作一个精品互动玩法',
|
||||
),
|
||||
within(getPlatformTabPanel('create')).getByRole('tablist', {
|
||||
name: '选择模板',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -508,6 +508,11 @@ function renderLoggedOutHomeView(
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
| 'recommendRuntimeContent'
|
||||
| 'activeRecommendEntryKey'
|
||||
| 'isStartingRecommendEntry'
|
||||
| 'recommendRuntimeError'
|
||||
| 'onSelectRecommendEntry'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
@@ -553,6 +558,15 @@ function renderLoggedOutHomeView(
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
recommendRuntimeContent={
|
||||
overrides.recommendRuntimeContent ?? (
|
||||
<div data-testid="recommend-runtime">运行内容</div>
|
||||
)
|
||||
}
|
||||
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
|
||||
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
|
||||
recommendRuntimeError={overrides.recommendRuntimeError}
|
||||
onSelectRecommendEntry={overrides.onSelectRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
@@ -562,7 +576,13 @@ function renderLoggedOutHomeView(
|
||||
|
||||
function renderStatefulLoggedOutHomeView(
|
||||
overrides: Partial<
|
||||
Pick<RpgEntryHomeViewProps, 'featuredEntries' | 'latestEntries'>
|
||||
Pick<
|
||||
RpgEntryHomeViewProps,
|
||||
| 'featuredEntries'
|
||||
| 'latestEntries'
|
||||
| 'onOpenGalleryDetail'
|
||||
| 'onSearchPublicCode'
|
||||
>
|
||||
> = {},
|
||||
) {
|
||||
function StatefulLoggedOutHomeView() {
|
||||
@@ -610,9 +630,10 @@ function renderStatefulLoggedOutHomeView(
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
|
||||
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
@@ -956,57 +977,12 @@ test('logged out bottom nav keeps creation centered with recommend icon', () =>
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile home search submits public work code', async () => {
|
||||
test('mobile discover search submits public work code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
setPlatformTheme: vi.fn(),
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
settingsError: null,
|
||||
}}
|
||||
>
|
||||
<RpgEntryHomeView
|
||||
activeTab="home"
|
||||
onTabChange={vi.fn()}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={[]}
|
||||
latestEntries={[]}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={null}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
isResumingSaveWorldKey={null}
|
||||
platformError={null}
|
||||
dashboardError={null}
|
||||
onContinueGame={vi.fn()}
|
||||
onResumeSave={vi.fn()}
|
||||
onOpenCreateWorld={vi.fn()}
|
||||
onOpenCreateTypePicker={vi.fn()}
|
||||
onOpenGalleryDetail={vi.fn()}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={onSearchPublicCode}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
renderStatefulLoggedOutHomeView({ onSearchPublicCode });
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'搜索作品号、名称、作者、描述',
|
||||
@@ -1016,7 +992,7 @@ test('mobile home search submits public work code', async () => {
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
});
|
||||
|
||||
test('home search fuzzy matches public work id, name, author and description', async () => {
|
||||
test('discover search fuzzy matches public work id, name, author and description', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
@@ -1041,46 +1017,52 @@ test('home search fuzzy matches public work id, name, author and description', a
|
||||
},
|
||||
] satisfies PlatformPublicGalleryCard[];
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: entries,
|
||||
onOpenGalleryDetail,
|
||||
onSearchPublicCode,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||||
if (!discoverPanel) {
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'MOON01{enter}');
|
||||
expect(await screen.findByText('搜索结果')).toBeTruthy();
|
||||
expect(screen.getByText('月井机关')).toBeTruthy();
|
||||
expect(screen.queryByText('火桥谜图')).toBeNull();
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).getByText('月井机关')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '火桥{enter}');
|
||||
expect(await screen.findByText('火桥谜图')).toBeTruthy();
|
||||
expect(screen.queryByText('月井机关')).toBeNull();
|
||||
expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('月井机关')).toBeNull();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '月井守望{enter}');
|
||||
expect(await screen.findByText('月井机关')).toBeTruthy();
|
||||
expect(screen.queryByText('火桥谜图')).toBeNull();
|
||||
expect(await within(discoverPanel).findByText('月井机关')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('火桥谜图')).toBeNull();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '熔岩断桥{enter}');
|
||||
expect(await screen.findByText('火桥谜图')).toBeTruthy();
|
||||
expect(screen.queryByText('月井机关')).toBeNull();
|
||||
expect(await within(discoverPanel).findByText('火桥谜图')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('月井机关')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /火桥谜图/u }));
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
|
||||
});
|
||||
|
||||
test('home search keeps public code fallback when local works do not match', async () => {
|
||||
test('discover search keeps public code fallback when local works do not match', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
onSearchPublicCode,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');
|
||||
@@ -1093,10 +1075,11 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
onOpenGalleryDetail,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
|
||||
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
|
||||
expect(
|
||||
@@ -1108,47 +1091,54 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
test('mobile public work cards render cover, author, kind and cover stats', () => {
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
test('mobile recommend page renders runtime viewport and bottom switcher', () => {
|
||||
const onSelectRecommendEntry = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectRecommendEntry,
|
||||
});
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u,
|
||||
});
|
||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__cover.aspect-video'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__cover-stats'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
card.querySelectorAll('.platform-public-work-card__cover-stat'),
|
||||
).toHaveLength(3);
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__kind')?.textContent,
|
||||
).toBe('拼图');
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__author-avatar')
|
||||
?.textContent,
|
||||
).toBe('拼');
|
||||
expect(screen.getByText('奇幻拼图')).toBeTruthy();
|
||||
document.querySelector('.platform-public-work-card__cover'),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('拼图玩家')).toBeTruthy();
|
||||
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
|
||||
expect(screen.getByText('奇幻')).toBeTruthy();
|
||||
expect(screen.getByText('20')).toBeTruthy();
|
||||
expect(screen.getByText('5')).toBeTruthy();
|
||||
expect(screen.getByText('12')).toBeTruthy();
|
||||
expect(card.querySelector('.platform-pill--warm')?.textContent).not.toBe(
|
||||
'推荐',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-home-channel--active')
|
||||
?.textContent,
|
||||
).toBe('推荐');
|
||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
|
||||
|
||||
const switchButton = screen.getByRole('button', {
|
||||
name: '切换到 奇幻拼图',
|
||||
});
|
||||
expect(switchButton.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
test('public work cards load real author avatar from public user summary', async () => {
|
||||
test('mobile recommend switcher selects a different public work', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelectRecommendEntry = vi.fn();
|
||||
const secondEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-second',
|
||||
profileId: 'puzzle-profile-second',
|
||||
publicWorkCode: 'PZ-SECOND',
|
||||
worldName: '第二拼图',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, secondEntry],
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
onSelectRecommendEntry,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '切换到 第二拼图' }));
|
||||
|
||||
expect(onSelectRecommendEntry).toHaveBeenCalledWith(secondEntry);
|
||||
});
|
||||
|
||||
test('mobile recommend meta loads real author avatar from public user summary', async () => {
|
||||
mockGetPublicAuthUserById.mockResolvedValueOnce({
|
||||
id: 'user-2',
|
||||
publicUserCode: 'SY-00000002',
|
||||
@@ -1159,16 +1149,13 @@ test('public work cards load real author avatar from public user summary', async
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
featuredEntries: [puzzlePublicEntry],
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u,
|
||||
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card
|
||||
.querySelector('.platform-public-work-card__author-avatar-image')
|
||||
document
|
||||
.querySelector('.platform-recommend-work-meta__avatar img')
|
||||
?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,AUTHOR');
|
||||
});
|
||||
@@ -1177,7 +1164,7 @@ test('public work cards load real author avatar from public user summary', async
|
||||
expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile home feed only rotates the card closest to screen center', () => {
|
||||
test('mobile discover recommend feed only rotates the card closest to screen center', async () => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
@@ -1199,9 +1186,12 @@ test('mobile home feed only rotates the card closest to screen center', () => {
|
||||
);
|
||||
const cardRects = new Map<string, DOMRect>();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [firstEntry, secondEntry],
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '发现' }).click();
|
||||
});
|
||||
|
||||
const tabPanel = document.querySelector('.platform-tab-panel--active');
|
||||
const firstCard = screen.getByRole('button', { name: /中心拼图一/u });
|
||||
@@ -1340,15 +1330,22 @@ test('mobile today channel only shows newly published works from today', async (
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '今日游戏' }));
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '今日' }));
|
||||
const discoverPanel = document.getElementById('platform-tab-panel-category');
|
||||
if (!discoverPanel) {
|
||||
throw new Error('缺少发现面板');
|
||||
}
|
||||
|
||||
expect(screen.getByRole('button', { name: /今日新游/u })).toBeTruthy();
|
||||
expect(screen.queryByText('昨日旧作')).toBeNull();
|
||||
expect(screen.queryByText('今日更新旧作')).toBeNull();
|
||||
expect(
|
||||
within(discoverPanel).getByRole('button', { name: /今日新游/u }),
|
||||
).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('昨日旧作')).toBeNull();
|
||||
expect(within(discoverPanel).queryByText('今日更新旧作')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop home syncs mobile home modules without square or latest labels', () => {
|
||||
@@ -1369,7 +1366,7 @@ test('desktop home syncs mobile home modules without square or latest labels', (
|
||||
});
|
||||
|
||||
expect(screen.getByText('今日游戏')).toBeTruthy();
|
||||
expect(screen.getByText('推荐')).toBeTruthy();
|
||||
expect(screen.getAllByText('推荐').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('作品分类')).toBeTruthy();
|
||||
expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('趋势关注')).toBeNull();
|
||||
@@ -1383,16 +1380,17 @@ test('desktop home syncs mobile home modules without square or latest labels', (
|
||||
|
||||
test('mobile home moves category shelf into game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
const { container } = renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('分类').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||||
@@ -1407,11 +1405,12 @@ test('mobile home moves category shelf into game category channel', async () =>
|
||||
test('mobile game category list orders works by composite public metric', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [puzzlePublicEntry, hotRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
const gameItems = Array.from(
|
||||
@@ -1427,8 +1426,7 @@ test('bottom category tab becomes ranking and switches ranking metrics', async (
|
||||
latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '分类' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
|
||||
@@ -1462,6 +1460,7 @@ test('ranking rows limit displayed work name and show two short tags on the thir
|
||||
latestEntries: [longTextRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '发现' }));
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
|
||||
@@ -118,6 +118,11 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
|
||||
recommendRuntimeContent?: ReactNode;
|
||||
activeRecommendEntryKey?: string | null;
|
||||
isStartingRecommendEntry?: boolean;
|
||||
recommendRuntimeError?: string | null;
|
||||
onSelectRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
@@ -656,6 +661,131 @@ function CreationLibraryCard({
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendRuntimeMeta({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
authorAvatarUrl?: string | null;
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const playCount = getPlatformWorldPlayCount(entry);
|
||||
const remixCount = getPlatformWorldRemixCount(entry);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
const authorName = entry.authorDisplayName.trim() || '玩家';
|
||||
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const statItems = [
|
||||
{ label: '游玩', value: playCount, icon: Gamepad2 },
|
||||
{ label: '点赞', value: likeCount, icon: Heart },
|
||||
{ label: '改造', value: remixCount, icon: MessageCircle },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-recommend-work-meta"
|
||||
aria-label={`${entry.worldName} 作品信息`}
|
||||
>
|
||||
<div className="platform-recommend-work-meta__stats">
|
||||
{statItems.map(({ label, value, icon: Icon }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="platform-recommend-work-meta__stat"
|
||||
aria-label={`${label} ${formatCompactCount(value)}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span>{formatCompactCount(value)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="platform-recommend-work-meta__row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
className="platform-recommend-work-meta__identity"
|
||||
aria-label={`打开 ${entry.worldName} 详情`}
|
||||
>
|
||||
<span
|
||||
className="platform-recommend-work-meta__avatar"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{normalizedAuthorAvatarUrl ? (
|
||||
<img
|
||||
src={normalizedAuthorAvatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
authorAvatarLabel
|
||||
)}
|
||||
</span>
|
||||
<span className="platform-recommend-work-meta__text">
|
||||
<span className="platform-recommend-work-meta__author">
|
||||
{authorName}
|
||||
</span>
|
||||
<span className="platform-recommend-work-meta__title">
|
||||
{displayName}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
className="platform-recommend-work-meta__detail-button"
|
||||
aria-label={`查看 ${entry.worldName} 详情`}
|
||||
title="详情"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecommendWorkSwitchItem({
|
||||
entry,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const typeLabel = describePublicGalleryCardKind(entry);
|
||||
const playCount = getPlatformWorldPlayCount(entry);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-label={`切换到 ${entry.worldName}`}
|
||||
aria-pressed={active}
|
||||
className={`platform-recommend-switch-card ${active ? 'platform-recommend-switch-card--active' : ''}`}
|
||||
>
|
||||
<span className="platform-recommend-switch-card__kind">{typeLabel}</span>
|
||||
<span className="platform-recommend-switch-card__title">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="platform-recommend-switch-card__stats">
|
||||
<span>
|
||||
<Gamepad2 className="h-3 w-3" aria-hidden="true" />
|
||||
{formatCompactCount(playCount)}
|
||||
</span>
|
||||
<span>
|
||||
<Heart className="h-3 w-3" aria-hidden="true" />
|
||||
{formatCompactCount(likeCount)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveArchiveCard({
|
||||
entry,
|
||||
onClick,
|
||||
@@ -2727,6 +2857,11 @@ export function RpgEntryHomeView({
|
||||
onResumeSave,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
recommendRuntimeContent,
|
||||
activeRecommendEntryKey = null,
|
||||
isStartingRecommendEntry = false,
|
||||
recommendRuntimeError = null,
|
||||
onSelectRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
deletingLibraryEntryId = null,
|
||||
@@ -2796,7 +2931,6 @@ export function RpgEntryHomeView({
|
||||
);
|
||||
const [discoverChannel, setDiscoverChannel] =
|
||||
useState<DiscoverChannel>('recommend');
|
||||
const mobileRecommendFeedRef = useRef<HTMLElement | null>(null);
|
||||
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
|
||||
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
|
||||
string | null
|
||||
@@ -3494,19 +3628,15 @@ export function RpgEntryHomeView({
|
||||
}, [discoverChannel, latestEntries, recommendedFeedEntries]);
|
||||
const mobileFeedCarouselEnabled =
|
||||
!isDesktopLayout &&
|
||||
((activeTab === 'home' && recommendedFeedEntries.length > 0) ||
|
||||
(activeTab === 'category' &&
|
||||
(discoverChannel === 'recommend' || discoverChannel === 'today')));
|
||||
activeTab === 'category' &&
|
||||
(discoverChannel === 'recommend' || discoverChannel === 'today');
|
||||
useEffect(() => {
|
||||
if (!mobileFeedCarouselEnabled) {
|
||||
setMobileCenteredCardKey(null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const feedElement =
|
||||
activeTab === 'home'
|
||||
? mobileRecommendFeedRef.current
|
||||
: mobileDiscoverFeedRef.current;
|
||||
const feedElement = mobileDiscoverFeedRef.current;
|
||||
const scrollElement = feedElement?.closest('.platform-tab-panel');
|
||||
if (!feedElement || !scrollElement) {
|
||||
setMobileCenteredCardKey(null);
|
||||
@@ -3577,13 +3707,7 @@ export function RpgEntryHomeView({
|
||||
scrollElement.removeEventListener('scroll', scheduleUpdate);
|
||||
window.removeEventListener('resize', scheduleUpdate);
|
||||
};
|
||||
}, [
|
||||
discoverChannel,
|
||||
discoverFeedEntries,
|
||||
activeTab,
|
||||
mobileFeedCarouselEnabled,
|
||||
recommendedFeedEntries,
|
||||
]);
|
||||
}, [discoverChannel, discoverFeedEntries, activeTab, mobileFeedCarouselEnabled]);
|
||||
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
|
||||
(tab) => tab.id === activeRankingTab,
|
||||
) as (typeof PLATFORM_RANKING_TABS)[number];
|
||||
@@ -3592,6 +3716,12 @@ export function RpgEntryHomeView({
|
||||
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
|
||||
[activeRankingTab, publicEntries],
|
||||
);
|
||||
const activeRecommendEntry =
|
||||
recommendedFeedEntries.find(
|
||||
(entry) => buildPublicGalleryCardKey(entry) === activeRecommendEntryKey,
|
||||
) ??
|
||||
recommendedFeedEntries[0] ??
|
||||
null;
|
||||
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -3663,36 +3793,75 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
ref={mobileRecommendFeedRef}
|
||||
className="platform-mobile-home-feed platform-mobile-recommend-feed"
|
||||
>
|
||||
<section className="platform-recommend-runtime-panel">
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : recommendedFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-4">
|
||||
{recommendedFeedEntries.map((entry) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
|
||||
return (
|
||||
<WorldCard
|
||||
key={`${cardKey}:mobile-recommend`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
|
||||
feedCardKey={cardKey}
|
||||
enableCoverCarousel={mobileFeedCarouselEnabled}
|
||||
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
|
||||
variant="immersive"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="platform-recommend-runtime-state">
|
||||
正在读取公开作品...
|
||||
</div>
|
||||
) : recommendRuntimeError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
activeRecommendEntry
|
||||
? onOpenGalleryDetail(activeRecommendEntry)
|
||||
: undefined
|
||||
}
|
||||
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
|
||||
>
|
||||
{recommendRuntimeError}
|
||||
</button>
|
||||
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
|
||||
<div className="platform-recommend-runtime-state">加载中...</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
{recommendRuntimeContent}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{activeRecommendEntry ? (
|
||||
<RecommendRuntimeMeta
|
||||
entry={activeRecommendEntry}
|
||||
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
|
||||
onOpenDetail={() => onOpenGalleryDetail(activeRecommendEntry)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{recommendedFeedEntries.length > 0 ? (
|
||||
<section
|
||||
className="platform-recommend-switcher"
|
||||
aria-label="推荐作品"
|
||||
>
|
||||
{recommendedFeedEntries.map((entry) => {
|
||||
const cardKey = buildPublicGalleryCardKey(entry);
|
||||
const active =
|
||||
activeRecommendEntryKey === cardKey ||
|
||||
Boolean(
|
||||
!activeRecommendEntryKey &&
|
||||
activeRecommendEntry &&
|
||||
buildPublicGalleryCardKey(activeRecommendEntry) === cardKey,
|
||||
);
|
||||
|
||||
return (
|
||||
<RecommendWorkSwitchItem
|
||||
key={`${cardKey}:recommend-switch`}
|
||||
entry={entry}
|
||||
active={active}
|
||||
onSelect={() => {
|
||||
if (onSelectRecommendEntry) {
|
||||
onSelectRecommendEntry(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenGalleryDetail(entry);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : !isLoadingPlatform ? (
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ type SquareHoleRuntimeShellProps = {
|
||||
run: SquareHoleRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
onBack: () => void;
|
||||
onRestart: () => void;
|
||||
onDropShape: (
|
||||
@@ -148,6 +149,7 @@ export function SquareHoleRuntimeShell({
|
||||
run,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
embedded = false,
|
||||
onBack,
|
||||
onRestart,
|
||||
onDropShape,
|
||||
@@ -327,7 +329,9 @@ export function SquareHoleRuntimeShell({
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
||||
<div
|
||||
className={`flex ${embedded ? 'h-full min-h-0 w-full' : 'min-h-dvh'} items-center justify-center bg-slate-950 text-white`}
|
||||
>
|
||||
{isBusy ? '载入中' : (error ?? '暂无运行态')}
|
||||
</div>
|
||||
);
|
||||
@@ -336,7 +340,9 @@ export function SquareHoleRuntimeShell({
|
||||
const feedback = run.lastFeedback;
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#101827] text-white">
|
||||
<main
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#101827] text-white`}
|
||||
>
|
||||
{run.backgroundImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={run.backgroundImageSrc}
|
||||
@@ -375,7 +381,7 @@ export function SquareHoleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100vw',
|
||||
|
||||
@@ -9,7 +9,11 @@ import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockDat
|
||||
import { VisualNovelResultView } from './VisualNovelResultView';
|
||||
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
createVisualNovelBackgroundMusicTask: vi.fn(),
|
||||
createVisualNovelSoundEffectTask: vi.fn(),
|
||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
||||
publishVisualNovelSoundEffectAsset: vi.fn(),
|
||||
uploadVisualNovelAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -91,8 +95,10 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
uploadMock.mockResolvedValue({
|
||||
assetObjectId: 'asset-scene-1',
|
||||
assetKind: 'scene_image',
|
||||
objectKey: 'generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
objectKey:
|
||||
'generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
imageSrc:
|
||||
'/generated-custom-world-scenes/vn-profile/scene-1/background.png',
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -112,9 +118,9 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
});
|
||||
await user.click(backgroundButtons[0]!);
|
||||
|
||||
const fileInput = within(screen.getByRole('dialog', { name: '背景图' })).getByLabelText(
|
||||
'上传背景图文件',
|
||||
) as HTMLInputElement;
|
||||
const fileInput = within(
|
||||
screen.getByRole('dialog', { name: '背景图' }),
|
||||
).getByLabelText('上传背景图文件') as HTMLInputElement;
|
||||
await user.upload(
|
||||
fileInput,
|
||||
new File(['image-bytes'], 'scene.png', { type: 'image/png' }),
|
||||
@@ -124,7 +130,7 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
|
||||
|
||||
expect(onSaveDraft).toHaveBeenCalled();
|
||||
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toContain(
|
||||
'/generated-custom-world-scenes/',
|
||||
);
|
||||
expect(
|
||||
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
|
||||
).toContain('/generated-custom-world-scenes/');
|
||||
});
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
PenLine,
|
||||
Play,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Waves,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
@@ -26,7 +28,11 @@ import type {
|
||||
VisualNovelValidationIssue,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import {
|
||||
createVisualNovelBackgroundMusicTask,
|
||||
createVisualNovelSoundEffectTask,
|
||||
listVisualNovelHistoryAssets,
|
||||
publishVisualNovelBackgroundMusicAsset,
|
||||
publishVisualNovelSoundEffectAsset,
|
||||
uploadVisualNovelAsset,
|
||||
type VisualNovelAssetReference,
|
||||
type VisualNovelHistoryAssetKind,
|
||||
@@ -98,6 +104,17 @@ type VisualNovelAssetPickerConfig = {
|
||||
previewTone: 'image' | 'audio';
|
||||
};
|
||||
|
||||
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
|
||||
|
||||
type VisualNovelAudioGeneratorConfig = {
|
||||
kind: VisualNovelAudioGeneratorKind;
|
||||
scene: VisualNovelSceneDraft;
|
||||
profileId?: string | null;
|
||||
};
|
||||
|
||||
const AUDIO_POLL_INTERVAL_MS = 3600;
|
||||
const AUDIO_POLL_MAX_ATTEMPTS = 36;
|
||||
|
||||
const RESULT_TABS: Array<{ id: VisualNovelResultTab; label: string }> = [
|
||||
{ id: 'profile', label: '作品' },
|
||||
{ id: 'world', label: '世界' },
|
||||
@@ -537,7 +554,9 @@ function VisualNovelAssetPickerDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoadingHistory && config.historyKind && historyAssets.length <= 0 ? (
|
||||
{!isLoadingHistory &&
|
||||
config.historyKind &&
|
||||
historyAssets.length <= 0 ? (
|
||||
<div className="flex min-h-40 items-center justify-center rounded-[1.15rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/56 text-sm text-[var(--platform-text-base)]">
|
||||
暂无历史素材
|
||||
</div>
|
||||
@@ -704,6 +723,299 @@ function VisualNovelAssetField({
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForVisualNovelGeneratedAudioAsset(
|
||||
config: VisualNovelAudioGeneratorConfig,
|
||||
taskId: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < AUDIO_POLL_MAX_ATTEMPTS; attempt += 1) {
|
||||
if (attempt > 0) {
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, AUDIO_POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sceneId: config.scene.sceneId,
|
||||
profileId: config.profileId ?? null,
|
||||
};
|
||||
const asset =
|
||||
config.kind === 'background_music'
|
||||
? await publishVisualNovelBackgroundMusicAsset(taskId, payload)
|
||||
: await publishVisualNovelSoundEffectAsset(taskId, payload);
|
||||
|
||||
if (asset.audioSrc?.trim()) {
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('音频生成仍在处理中,请稍后重试。');
|
||||
}
|
||||
|
||||
function buildDefaultAudioPrompt(
|
||||
kind: VisualNovelAudioGeneratorKind,
|
||||
scene: VisualNovelSceneDraft,
|
||||
) {
|
||||
const name = scene.name.trim() || '当前场景';
|
||||
const description = scene.description.trim();
|
||||
if (kind === 'background_music') {
|
||||
return [name, description, '适合作为视觉小说循环播放的无歌词背景音乐']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
return [name, description, '短促、清晰、适合场景切换时播放的环境音效']
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function VisualNovelAudioGeneratorDialog({
|
||||
config,
|
||||
disabled,
|
||||
onClose,
|
||||
onGenerated,
|
||||
}: {
|
||||
config: VisualNovelAudioGeneratorConfig;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
onGenerated: (asset: VisualNovelAssetReference) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const platformTheme = authUi?.platformTheme ?? 'light';
|
||||
const isBackgroundMusic = config.kind === 'background_music';
|
||||
const [prompt, setPrompt] = useState(() =>
|
||||
buildDefaultAudioPrompt(config.kind, config.scene),
|
||||
);
|
||||
const [title, setTitle] = useState(() =>
|
||||
(config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40),
|
||||
);
|
||||
const [tags, setTags] = useState('cinematic, ambient, emotional');
|
||||
const [duration, setDuration] = useState(5);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt(buildDefaultAudioPrompt(config.kind, config.scene));
|
||||
setTitle((config.scene.name.trim() || '视觉小说场景音乐').slice(0, 40));
|
||||
setTags('cinematic, ambient, emotional');
|
||||
setDuration(5);
|
||||
setError(null);
|
||||
}, [config]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('提示词不能为空。');
|
||||
return;
|
||||
}
|
||||
if (isBackgroundMusic && !title.trim()) {
|
||||
setError('标题不能为空。');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const task = isBackgroundMusic
|
||||
? await createVisualNovelBackgroundMusicTask({
|
||||
prompt,
|
||||
title,
|
||||
tags: tags.trim() || null,
|
||||
model: 'chirp-v4',
|
||||
})
|
||||
: await createVisualNovelSoundEffectTask({
|
||||
prompt,
|
||||
duration,
|
||||
});
|
||||
const asset = await waitForVisualNovelGeneratedAudioAsset(
|
||||
config,
|
||||
task.taskId,
|
||||
);
|
||||
onGenerated({
|
||||
assetObjectId: asset.assetObjectId ?? task.taskId,
|
||||
assetKind:
|
||||
asset.assetKind ??
|
||||
(isBackgroundMusic
|
||||
? 'visual_novel_music'
|
||||
: 'visual_novel_ambient_sound'),
|
||||
objectKey: '',
|
||||
imageSrc: asset.audioSrc ?? '',
|
||||
profileId: config.profileId ?? null,
|
||||
entityId: config.scene.sceneId,
|
||||
});
|
||||
onClose();
|
||||
} catch (generateError) {
|
||||
setError(
|
||||
generateError instanceof Error
|
||||
? generateError.message
|
||||
: '音频生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[180] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGenerating) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isBackgroundMusic ? '生成音乐' : '生成音效'}
|
||||
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,34rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
|
||||
<h2 className="min-w-0 truncate text-base font-black text-[var(--platform-text-strong)]">
|
||||
{isBackgroundMusic ? '生成音乐' : '生成音效'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-icon-button h-9 w-9"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{isBackgroundMusic ? (
|
||||
<>
|
||||
<label className="block">
|
||||
<FieldLabel>标题</FieldLabel>
|
||||
<input
|
||||
value={title}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>风格</FieldLabel>
|
||||
<input
|
||||
value={tags}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="block">
|
||||
<FieldLabel>时长</FieldLabel>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={10}
|
||||
step={1}
|
||||
value={duration}
|
||||
disabled={disabled || isGenerating}
|
||||
onChange={(event) => setDuration(Number(event.target.value))}
|
||||
className="mt-3 w-full accent-[var(--platform-primary)]"
|
||||
/>
|
||||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{duration} 秒
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<FieldLabel>提示词</FieldLabel>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={disabled || isGenerating}
|
||||
rows={5}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isGenerating}
|
||||
onClick={onClose}
|
||||
className="platform-button platform-button--ghost min-h-10 px-4 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isGenerating}
|
||||
onClick={() => {
|
||||
void handleGenerate();
|
||||
}}
|
||||
className="platform-button platform-button--primary min-h-10 px-4 text-sm"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
生成
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelAudioGenerateButton({
|
||||
config,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onGenerated,
|
||||
}: {
|
||||
config: VisualNovelAudioGeneratorConfig;
|
||||
disabled: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onGenerated: (asset: VisualNovelAssetReference) => void;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<VisualNovelAudioGeneratorDialog
|
||||
config={config}
|
||||
disabled={disabled}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onGenerated={onGenerated}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelProfileTab({
|
||||
draft,
|
||||
disabled,
|
||||
@@ -777,7 +1089,10 @@ function VisualNovelProfileTab({
|
||||
value={draft.workTags.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...draft, workTags: normalizeTags(event.target.value) })
|
||||
onChange({
|
||||
...draft,
|
||||
workTags: normalizeTags(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -870,7 +1185,9 @@ function VisualNovelOpeningTab({
|
||||
<label>
|
||||
<FieldLabel>初始选项</FieldLabel>
|
||||
<textarea
|
||||
value={draft.opening.initialChoices.map((choice) => choice.text).join('\n')}
|
||||
value={draft.opening.initialChoices
|
||||
.map((choice) => choice.text)
|
||||
.join('\n')}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
@@ -878,16 +1195,16 @@ function VisualNovelOpeningTab({
|
||||
...draft,
|
||||
opening: {
|
||||
...draft.opening,
|
||||
initialChoices: normalizeListInput(event.target.value).slice(0, 4).map(
|
||||
(text, index) => ({
|
||||
initialChoices: normalizeListInput(event.target.value)
|
||||
.slice(0, 4)
|
||||
.map((text, index) => ({
|
||||
choiceId:
|
||||
draft.opening.initialChoices[index]?.choiceId ??
|
||||
`${draft.profileId ?? 'vn'}-opening-choice-${index + 1}`,
|
||||
text,
|
||||
actionHint:
|
||||
draft.opening.initialChoices[index]?.actionHint ?? null,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -984,7 +1301,8 @@ function VisualNovelRuntimeConfigTab({
|
||||
...draft,
|
||||
runtimeConfig: {
|
||||
...draft.runtimeConfig,
|
||||
attributePanelMode: event.target.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
|
||||
attributePanelMode: event.target
|
||||
.value as VisualNovelResultDraft['runtimeConfig']['attributePanelMode'],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1113,10 +1431,12 @@ function VisualNovelCharacterEditor({
|
||||
function VisualNovelSceneEditor({
|
||||
item,
|
||||
disabled,
|
||||
profileId,
|
||||
onChange,
|
||||
}: {
|
||||
item: VisualNovelSceneDraft;
|
||||
disabled: boolean;
|
||||
profileId?: string | null;
|
||||
onChange: (item: VisualNovelSceneDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1151,7 +1471,8 @@ function VisualNovelSceneEditor({
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...item,
|
||||
availability: event.target.value as VisualNovelSceneAvailability,
|
||||
availability: event.target
|
||||
.value as VisualNovelSceneAvailability,
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
@@ -1169,7 +1490,10 @@ function VisualNovelSceneEditor({
|
||||
value={item.phaseIds.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...item, phaseIds: normalizeListInput(event.target.value) })
|
||||
onChange({
|
||||
...item,
|
||||
phaseIds: normalizeListInput(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -1183,7 +1507,7 @@ function VisualNovelSceneEditor({
|
||||
uploadKind="scene_background"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
historyKind="scene_image"
|
||||
profileId={null}
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="image"
|
||||
onSelect={(asset) =>
|
||||
@@ -1197,11 +1521,49 @@ function VisualNovelSceneEditor({
|
||||
disabled={disabled}
|
||||
uploadKind="music"
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
profileId={null}
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="audio"
|
||||
onSelect={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
|
||||
/>
|
||||
<VisualNovelAudioGenerateButton
|
||||
label="生成音乐"
|
||||
icon={Sparkles}
|
||||
disabled={disabled}
|
||||
config={{
|
||||
kind: 'background_music',
|
||||
scene: item,
|
||||
profileId: profileId ?? null,
|
||||
}}
|
||||
onGenerated={(asset) => onChange({ ...item, musicSrc: asset.imageSrc })}
|
||||
/>
|
||||
<VisualNovelAssetField
|
||||
label="音效"
|
||||
icon={Waves}
|
||||
assetSrc={item.ambientSoundSrc}
|
||||
disabled={disabled}
|
||||
uploadKind="ambient_sound"
|
||||
accept="audio/mpeg,audio/ogg,audio/wav,audio/webm"
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="audio"
|
||||
onSelect={(asset) =>
|
||||
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
|
||||
}
|
||||
/>
|
||||
<VisualNovelAudioGenerateButton
|
||||
label="生成音效"
|
||||
icon={Sparkles}
|
||||
disabled={disabled}
|
||||
config={{
|
||||
kind: 'sound_effect',
|
||||
scene: item,
|
||||
profileId: profileId ?? null,
|
||||
}}
|
||||
onGenerated={(asset) =>
|
||||
onChange({ ...item, ambientSoundSrc: asset.imageSrc })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1238,7 +1600,10 @@ function VisualNovelPhaseEditor({
|
||||
value={item.sceneIds.join(',')}
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
onChange({ ...item, sceneIds: normalizeListInput(event.target.value) })
|
||||
onChange({
|
||||
...item,
|
||||
sceneIds: normalizeListInput(event.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
@@ -1334,10 +1699,24 @@ function VisualNovelEntityGrid({
|
||||
onCreate,
|
||||
onOpen,
|
||||
}: {
|
||||
items: Array<VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft>;
|
||||
items: Array<
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft
|
||||
>;
|
||||
emptyText: string;
|
||||
getTitle: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
|
||||
getMeta: (item: VisualNovelCharacterDraft | VisualNovelSceneDraft | VisualNovelStoryPhaseDraft) => string;
|
||||
getTitle: (
|
||||
item:
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft,
|
||||
) => string;
|
||||
getMeta: (
|
||||
item:
|
||||
| VisualNovelCharacterDraft
|
||||
| VisualNovelSceneDraft
|
||||
| VisualNovelStoryPhaseDraft,
|
||||
) => string;
|
||||
kind: VisualNovelEditorKind;
|
||||
onCreate: () => void;
|
||||
onOpen: (target: VisualNovelEditorTarget) => void;
|
||||
@@ -1371,7 +1750,10 @@ function VisualNovelEntityGrid({
|
||||
} else if (kind === 'scene') {
|
||||
onOpen({ kind, item: item as VisualNovelSceneDraft });
|
||||
} else {
|
||||
onOpen({ kind: 'phase', item: item as VisualNovelStoryPhaseDraft });
|
||||
onOpen({
|
||||
kind: 'phase',
|
||||
item: item as VisualNovelStoryPhaseDraft,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="platform-subpanel min-h-32 rounded-[1.25rem] p-4 text-left transition hover:-translate-y-0.5"
|
||||
@@ -1515,6 +1897,7 @@ function VisualNovelEditorDialog({
|
||||
<VisualNovelSceneEditor
|
||||
item={target.item}
|
||||
disabled={disabled}
|
||||
profileId={draft.profileId}
|
||||
onChange={updateScene}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1561,11 +1944,12 @@ export function VisualNovelResultView({
|
||||
onStartTestRun,
|
||||
onPublish,
|
||||
}: VisualNovelResultViewProps) {
|
||||
const [editDraft, setEditDraft] = useState(() => cloneDraft(draft ?? mockVisualNovelDraft));
|
||||
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
|
||||
const [editorTarget, setEditorTarget] = useState<VisualNovelEditorTarget | null>(
|
||||
null,
|
||||
const [editDraft, setEditDraft] = useState(() =>
|
||||
cloneDraft(draft ?? mockVisualNovelDraft),
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<VisualNovelResultTab>('profile');
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<VisualNovelEditorTarget | null>(null);
|
||||
const blockers = useMemo(() => buildPublishBlockers(editDraft), [editDraft]);
|
||||
const canPublish = blockers.length === 0;
|
||||
const publishIssues = useMemo(
|
||||
@@ -1612,7 +1996,9 @@ export function VisualNovelResultView({
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setEditorTarget({ kind: 'runtime', item: editDraft })}
|
||||
onClick={() =>
|
||||
setEditorTarget({ kind: 'runtime', item: editDraft })
|
||||
}
|
||||
className="platform-icon-button h-10 w-10"
|
||||
aria-label="运行配置"
|
||||
title="运行配置"
|
||||
@@ -1675,7 +2061,10 @@ export function VisualNovelResultView({
|
||||
onCreate={() =>
|
||||
updateDraft({
|
||||
...editDraft,
|
||||
scenes: [...editDraft.scenes, createSceneDraft(editDraft.scenes.length)],
|
||||
scenes: [
|
||||
...editDraft.scenes,
|
||||
createSceneDraft(editDraft.scenes.length),
|
||||
],
|
||||
})
|
||||
}
|
||||
onOpen={setEditorTarget}
|
||||
|
||||
@@ -39,6 +39,7 @@ type VisualNovelRuntimeShellProps = {
|
||||
isLoadingArchives?: boolean;
|
||||
resumingWorldKey?: string | null;
|
||||
error?: string | null;
|
||||
embedded?: boolean;
|
||||
streamedSteps?: VisualNovelRuntimeStep[];
|
||||
streamingText?: string;
|
||||
saveArchives?: ProfileSaveArchiveSummary[];
|
||||
@@ -224,6 +225,7 @@ export function VisualNovelRuntimeShell({
|
||||
isLoadingArchives = false,
|
||||
resumingWorldKey = null,
|
||||
error,
|
||||
embedded = false,
|
||||
streamedSteps = [],
|
||||
streamingText = '',
|
||||
saveArchives,
|
||||
@@ -383,7 +385,9 @@ export function VisualNovelRuntimeShell({
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#111827] text-white">
|
||||
<main
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#111827] text-white`}
|
||||
>
|
||||
{backgroundImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundImageSrc}
|
||||
@@ -395,7 +399,7 @@ export function VisualNovelRuntimeShell({
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.88)),linear-gradient(90deg,rgba(0,0,0,0.32),transparent_36%,rgba(0,0,0,0.38))]" />
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]`}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100vw',
|
||||
|
||||
Reference in New Issue
Block a user