This commit is contained in:
2026-05-08 20:48:29 +08:00
parent abf1f1ebea
commit 94975e4735
82 changed files with 7786 additions and 1012 deletions

View File

@@ -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: '确认裁剪并上传' }));

View File

@@ -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 ? (

View File

@@ -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)]"

View File

@@ -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">

View File

@@ -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

View File

@@ -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(() => {

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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();
});

View File

@@ -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: /20512/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: /20512/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');

View File

@@ -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>
);

View File

@@ -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',

View File

@@ -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/');
});

View File

@@ -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}

View File

@@ -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',