收口生成队列与图片预览
将外部生成队列概览移到我的页签展示 移除生成页和进度页中的队列概览区域 新增全屏黑底图片预览器并支持缩放和边界拖拽 补充队列概览和图片预览的聚焦测试 同步更新玩法链路、运维、UI Kit 和团队共享记忆文档
This commit is contained in:
@@ -31,16 +31,8 @@ interface CustomWorldGenerationViewProps {
|
||||
idleBadgeLabel?: string;
|
||||
structuredEmptyText?: string;
|
||||
hideBatchModule?: boolean;
|
||||
queueStatus?: ExternalGenerationQueueStatus | null;
|
||||
}
|
||||
|
||||
export type ExternalGenerationQueueStatus = {
|
||||
currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null;
|
||||
currentProgress?: number | null;
|
||||
pendingCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
};
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
@@ -93,49 +85,6 @@ function getStepStatusLabel(step: { status: string }) {
|
||||
return '待处理';
|
||||
}
|
||||
|
||||
function resolveQueueStatusLabel(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
if (status === 'queued') {
|
||||
return '排队中';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return '生成中';
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return '生成失败';
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return '已完成';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasQueueStatus(status: ExternalGenerationQueueStatus | null | undefined) {
|
||||
return Boolean(
|
||||
status &&
|
||||
(status.currentStatus ||
|
||||
typeof status.pendingCount === 'number' ||
|
||||
typeof status.runningCount === 'number'),
|
||||
);
|
||||
}
|
||||
|
||||
function formatQueueCount(value: number | null | undefined) {
|
||||
return Math.max(0, Math.round(value ?? 0)).toString();
|
||||
}
|
||||
|
||||
function formatQueueProgress(value: number | null | undefined) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${Math.max(0, Math.min(100, Math.round(value)))}%`;
|
||||
}
|
||||
|
||||
function resolveCurrentGenerationStep(
|
||||
progress: CustomWorldGenerationProgress | null,
|
||||
) {
|
||||
@@ -162,7 +111,6 @@ export function CustomWorldGenerationView({
|
||||
activeBadgeLabel = '世界建设中',
|
||||
idleBadgeLabel = '等待操作',
|
||||
hideBatchModule = false,
|
||||
queueStatus = null,
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
void hideBatchModule;
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
@@ -183,11 +131,6 @@ export function CustomWorldGenerationView({
|
||||
: '校准中';
|
||||
const elapsedText =
|
||||
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
|
||||
const queueStatusLabel = resolveQueueStatusLabel(
|
||||
queueStatus?.currentStatus ?? null,
|
||||
);
|
||||
const queueProgressText = formatQueueProgress(queueStatus?.currentProgress);
|
||||
const shouldShowQueueStatus = hasQueueStatus(queueStatus);
|
||||
|
||||
return (
|
||||
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5">
|
||||
@@ -224,21 +167,6 @@ export function CustomWorldGenerationView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowQueueStatus ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-[1.25rem] border border-white/70 bg-white/72 px-3 py-2 text-xs font-semibold text-[#6b3a1d] shadow-[0_14px_34px_rgba(121,70,33,0.10)] backdrop-blur-md sm:px-4">
|
||||
{queueStatusLabel ? (
|
||||
<span className="rounded-full bg-[#fff4dc] px-2.5 py-1 text-[#8a4c1e]">
|
||||
{queueProgressText
|
||||
? `${queueStatusLabel} ${queueProgressText}`
|
||||
: queueStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span>排队 {formatQueueCount(queueStatus?.pendingCount)}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[#d4a15d]" />
|
||||
<span>生成 {formatQueueCount(queueStatus?.runningCount)}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<PlatformActionButton
|
||||
|
||||
@@ -256,7 +256,7 @@ test('creative image input panel confirms before removing uploaded image', () =>
|
||||
expect(onMainImageRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel closes reference preview on backdrop click', () => {
|
||||
test('creative image input panel closes reference preview with close button', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc=""
|
||||
@@ -299,8 +299,8 @@ test('creative image input panel closes reference preview on backdrop click', ()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览参考图 参考图 1' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '参考图 1' });
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
expect(screen.getByRole('dialog', { name: '参考图 1' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭参考图预览' }));
|
||||
expect(screen.queryByRole('dialog', { name: '参考图 1' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -400,7 +400,12 @@ test('creative image input panel can preview the main image and keep upload on a
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
|
||||
const previewDialog = screen.getByRole('dialog', {
|
||||
name: '查看关卡图片',
|
||||
});
|
||||
expect(previewDialog.className).toContain('platform-image-preview-modal');
|
||||
expect(previewDialog.className).toContain('bg-black');
|
||||
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
|
||||
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(
|
||||
2,
|
||||
);
|
||||
@@ -428,7 +433,7 @@ test('creative image input panel can preview the main image and keep upload on a
|
||||
}
|
||||
});
|
||||
|
||||
test('creative image input panel closes main image preview on backdrop click', () => {
|
||||
test('creative image input panel closes main image preview with close button', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageClickMode="preview"
|
||||
@@ -466,8 +471,8 @@ test('creative image input panel closes main image preview on backdrop click', (
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
|
||||
fireEvent.click(dialog.parentElement as HTMLElement);
|
||||
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭关卡图片预览' }));
|
||||
expect(screen.queryByRole('dialog', { name: '查看关卡图片' })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
|
||||
import { PlatformPillBadge } from './PlatformPillBadge';
|
||||
import { PlatformPillSwitch } from './PlatformPillSwitch';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import { PlatformTextField } from './PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
|
||||
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
@@ -492,58 +492,28 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UnifiedModal
|
||||
<PlatformImagePreviewModal
|
||||
open={Boolean(previewReferenceImage)}
|
||||
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
|
||||
onClose={() => setPreviewReferenceImage(null)}
|
||||
imageSrc={previewReferenceImage?.imageSrc ?? null}
|
||||
imageAlt={labels.promptReferencePreviewAlt}
|
||||
closeLabel={labels.closePromptReferencePreview}
|
||||
closeVariant="profileCompact"
|
||||
size="lg"
|
||||
zIndexClassName="z-[80]"
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{previewReferenceImage ? (
|
||||
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={previewReferenceImage.imageSrc}
|
||||
alt={labels.promptReferencePreviewAlt}
|
||||
className="h-full max-h-[72vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
onClose={() => setPreviewReferenceImage(null)}
|
||||
/>
|
||||
|
||||
<UnifiedModal
|
||||
<PlatformImagePreviewModal
|
||||
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClose={() => setIsMainImagePreviewOpen(false)}
|
||||
imageSrc={uploadedImageSrc}
|
||||
imageAlt={uploadedImageAlt}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
closeLabel={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
closeVariant="profileCompact"
|
||||
size="xl"
|
||||
zIndexClassName={mainImagePreviewZIndexClassName}
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{uploadedImageSrc ? (
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
onClose={() => setIsMainImagePreviewOpen(false)}
|
||||
/>
|
||||
|
||||
<UnifiedConfirmDialog
|
||||
open={isRemoveImageConfirmOpen}
|
||||
|
||||
181
src/components/common/PlatformImagePreviewModal.test.tsx
Normal file
181
src/components/common/PlatformImagePreviewModal.test.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
|
||||
import { clampPlatformImagePreviewTransform } from './platformImagePreviewModel';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
style,
|
||||
draggable,
|
||||
onLoad,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
draggable?: boolean;
|
||||
onLoad?: React.ReactEventHandler<HTMLImageElement>;
|
||||
}) =>
|
||||
src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={style}
|
||||
draggable={draggable}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
test('clamps full-screen image preview zoom and drag within image bounds', () => {
|
||||
expect(
|
||||
clampPlatformImagePreviewTransform(
|
||||
{
|
||||
scale: 8,
|
||||
offsetX: 999,
|
||||
offsetY: -999,
|
||||
},
|
||||
{ width: 400, height: 800 },
|
||||
{ width: 400, height: 400 },
|
||||
),
|
||||
).toEqual({
|
||||
scale: 4,
|
||||
offsetX: 600,
|
||||
offsetY: -400,
|
||||
});
|
||||
|
||||
expect(
|
||||
clampPlatformImagePreviewTransform(
|
||||
{
|
||||
scale: 4,
|
||||
offsetX: -999,
|
||||
offsetY: 999,
|
||||
},
|
||||
{ width: 400, height: 800 },
|
||||
{ width: 400, height: 400 },
|
||||
),
|
||||
).toEqual({
|
||||
scale: 4,
|
||||
offsetX: -600,
|
||||
offsetY: 400,
|
||||
});
|
||||
|
||||
expect(
|
||||
clampPlatformImagePreviewTransform(
|
||||
{
|
||||
scale: 0.25,
|
||||
offsetX: 80,
|
||||
offsetY: 80,
|
||||
},
|
||||
{ width: 400, height: 800 },
|
||||
{ width: 400, height: 400 },
|
||||
),
|
||||
).toEqual({
|
||||
scale: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('renders full-screen image preview with zoom controls and dark backdrop', () => {
|
||||
render(
|
||||
<PlatformImagePreviewModal
|
||||
open
|
||||
title="查看关卡图片"
|
||||
imageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
imageAlt="拼图关卡图"
|
||||
closeLabel="关闭关卡图片预览"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '查看关卡图片' });
|
||||
expect(dialog.className).toContain('platform-image-preview-modal');
|
||||
expect(dialog.className).toContain('bg-black');
|
||||
expect(
|
||||
dialog.parentElement?.className.includes('!bg-black'),
|
||||
).toBe(true);
|
||||
expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy();
|
||||
expect(screen.getByAltText('拼图关卡图').getAttribute('draggable')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('zooms to at most four times and clamps dragged image position', async () => {
|
||||
render(
|
||||
<PlatformImagePreviewModal
|
||||
open
|
||||
title="查看关卡图片"
|
||||
imageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
imageAlt="拼图关卡图"
|
||||
closeLabel="关闭关卡图片预览"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const stage = screen.getByTestId('platform-image-preview-stage');
|
||||
Object.defineProperty(stage, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
width: 400,
|
||||
height: 800,
|
||||
top: 0,
|
||||
right: 400,
|
||||
bottom: 800,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const image = screen.getByAltText('拼图关卡图') as HTMLImageElement;
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { configurable: true, value: 400 },
|
||||
naturalHeight: { configurable: true, value: 400 },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
fireEvent.load(image);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(image.style.width).toBe('400px');
|
||||
expect(image.style.height).toBe('400px');
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大图片' }));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(image.style.transform).toContain(
|
||||
'translate3d(0px, 0px, 0) scale(4)',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(stage, { pointerId: 1, clientX: 200, clientY: 400 });
|
||||
fireEvent.pointerMove(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
|
||||
fireEvent.pointerUp(stage, { pointerId: 1, clientX: 1200, clientY: 1200 });
|
||||
|
||||
await waitFor(() => {
|
||||
const offsetMatch = image.style.transform.match(
|
||||
/translate3d\((-?\d+)px, (-?\d+)px, 0\) scale\(4\)/u,
|
||||
);
|
||||
|
||||
expect(offsetMatch).not.toBeNull();
|
||||
expect(Math.abs(Number(offsetMatch?.[1]))).toBe(600);
|
||||
expect(Math.abs(Number(offsetMatch?.[2]))).toBe(400);
|
||||
});
|
||||
});
|
||||
332
src/components/common/PlatformImagePreviewModal.tsx
Normal file
332
src/components/common/PlatformImagePreviewModal.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { Minus, Plus, RotateCcw } from 'lucide-react';
|
||||
import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
} from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
import {
|
||||
clampPlatformImagePreviewTransform,
|
||||
DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
|
||||
MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
|
||||
MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
|
||||
PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
|
||||
type PlatformImagePreviewSize,
|
||||
type PlatformImagePreviewTransform,
|
||||
resolvePlatformImagePreviewContainedSize,
|
||||
samePlatformImagePreviewTransform,
|
||||
} from './platformImagePreviewModel';
|
||||
import { PlatformModalCloseButton } from './PlatformModalCloseButton';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
type PlatformImagePreviewModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
imageSrc: string | null;
|
||||
imageAlt: string;
|
||||
refreshKey?: string | number | null;
|
||||
closeLabel: string;
|
||||
zIndexClassName?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type DragState = {
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startOffsetX: number;
|
||||
startOffsetY: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全屏图片查看器。
|
||||
* 1x 保持完整图片可见;放大后拖拽会按图片边界夹住,避免拖出空背景。
|
||||
*/
|
||||
export function PlatformImagePreviewModal({
|
||||
open,
|
||||
title,
|
||||
imageSrc,
|
||||
imageAlt,
|
||||
refreshKey = null,
|
||||
closeLabel,
|
||||
zIndexClassName = 'z-[110]',
|
||||
onClose,
|
||||
}: PlatformImagePreviewModalProps) {
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const [viewportSize, setViewportSize] = useState<PlatformImagePreviewSize>({
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
const [naturalSize, setNaturalSize] =
|
||||
useState<PlatformImagePreviewSize | null>(null);
|
||||
const [transform, setTransform] = useState<PlatformImagePreviewTransform>(
|
||||
DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM,
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const containedImageSize = useMemo(
|
||||
() => resolvePlatformImagePreviewContainedSize(viewportSize, naturalSize),
|
||||
[naturalSize, viewportSize],
|
||||
);
|
||||
|
||||
const clampTransform = useCallback(
|
||||
(nextTransform: PlatformImagePreviewTransform) =>
|
||||
clampPlatformImagePreviewTransform(
|
||||
nextTransform,
|
||||
viewportSize,
|
||||
naturalSize,
|
||||
),
|
||||
[naturalSize, viewportSize],
|
||||
);
|
||||
|
||||
const updateViewportSize = useCallback(() => {
|
||||
const rect = stageRef.current?.getBoundingClientRect();
|
||||
setViewportSize({
|
||||
width: rect?.width || window.innerWidth || 1,
|
||||
height: rect?.height || window.innerHeight || 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateTransform = useCallback(
|
||||
(
|
||||
updater: (
|
||||
current: PlatformImagePreviewTransform,
|
||||
) => PlatformImagePreviewTransform,
|
||||
) => {
|
||||
setTransform((current) => {
|
||||
const nextTransform = clampTransform(updater(current));
|
||||
return samePlatformImagePreviewTransform(current, nextTransform)
|
||||
? current
|
||||
: nextTransform;
|
||||
});
|
||||
},
|
||||
[clampTransform],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
|
||||
setNaturalSize(null);
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
}, [imageSrc, open, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateViewportSize();
|
||||
const frameId = window.requestAnimationFrame(updateViewportSize);
|
||||
window.addEventListener('resize', updateViewportSize);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
window.removeEventListener('resize', updateViewportSize);
|
||||
};
|
||||
}, [open, updateViewportSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setTransform((current) => {
|
||||
const nextTransform = clampTransform(current);
|
||||
return samePlatformImagePreviewTransform(current, nextTransform)
|
||||
? current
|
||||
: nextTransform;
|
||||
});
|
||||
}, [clampTransform]);
|
||||
|
||||
const zoomBy = useCallback(
|
||||
(scaleDelta: number) => {
|
||||
updateTransform((current) => ({
|
||||
...current,
|
||||
scale: current.scale + scaleDelta,
|
||||
}));
|
||||
},
|
||||
[updateTransform],
|
||||
);
|
||||
|
||||
const resetTransform = useCallback(() => {
|
||||
setTransform(DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
zoomBy(
|
||||
event.deltaY < 0
|
||||
? PLATFORM_IMAGE_PREVIEW_SCALE_STEP
|
||||
: -PLATFORM_IMAGE_PREVIEW_SCALE_STEP,
|
||||
);
|
||||
},
|
||||
[zoomBy],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (transform.scale <= MIN_PLATFORM_IMAGE_PREVIEW_SCALE) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startOffsetX: transform.offsetX,
|
||||
startOffsetY: transform.offsetY,
|
||||
scale: transform.scale,
|
||||
};
|
||||
setIsDragging(true);
|
||||
},
|
||||
[transform],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const deltaX = event.clientX - dragState.startClientX;
|
||||
const deltaY = event.clientY - dragState.startClientY;
|
||||
updateTransform(() => ({
|
||||
scale: dragState.scale,
|
||||
offsetX: dragState.startOffsetX + deltaX,
|
||||
offsetY: dragState.startOffsetY + deltaY,
|
||||
}));
|
||||
},
|
||||
[updateTransform],
|
||||
);
|
||||
|
||||
const finishPointerDrag = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (dragStateRef.current?.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const imageStyle = {
|
||||
width: `${containedImageSize.width}px`,
|
||||
height: `${containedImageSize.height}px`,
|
||||
transform: `translate3d(${transform.offsetX}px, ${transform.offsetY}px, 0) scale(${transform.scale})`,
|
||||
};
|
||||
const canZoomOut = transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE;
|
||||
const canZoomIn = transform.scale < MAX_PLATFORM_IMAGE_PREVIEW_SCALE;
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(imageSrc)}
|
||||
title={title}
|
||||
ariaLabel={title}
|
||||
onClose={onClose}
|
||||
closeLabel={closeLabel}
|
||||
closeOnBackdrop={false}
|
||||
showHeader={false}
|
||||
showCloseButton={false}
|
||||
size="fullscreen"
|
||||
zIndexClassName={zIndexClassName}
|
||||
overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none"
|
||||
panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none"
|
||||
bodyClassName="relative flex h-full min-h-0 flex-col !overflow-hidden !p-0"
|
||||
>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 flex items-center justify-between gap-3 px-4 py-[max(0.85rem,env(safe-area-inset-top))]">
|
||||
<div className="pointer-events-auto flex items-center gap-2">
|
||||
<PlatformIconButton
|
||||
variant="darkMini"
|
||||
label="缩小图片"
|
||||
title="缩小图片"
|
||||
disabled={!canZoomOut}
|
||||
icon={<Minus className="h-4 w-4" />}
|
||||
onClick={() => zoomBy(-PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
|
||||
className="h-9 w-9"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
variant="darkMini"
|
||||
label="重置图片缩放"
|
||||
title="重置图片缩放"
|
||||
disabled={!canZoomOut}
|
||||
icon={<RotateCcw className="h-4 w-4" />}
|
||||
onClick={resetTransform}
|
||||
className="h-9 w-9"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
variant="darkMini"
|
||||
label="放大图片"
|
||||
title="放大图片"
|
||||
disabled={!canZoomIn}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
onClick={() => zoomBy(PLATFORM_IMAGE_PREVIEW_SCALE_STEP)}
|
||||
className="h-9 w-9"
|
||||
/>
|
||||
</div>
|
||||
<PlatformModalCloseButton
|
||||
variant="editorDark"
|
||||
placement="inline"
|
||||
label={closeLabel}
|
||||
onClick={onClose}
|
||||
className="pointer-events-auto h-9 w-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={stageRef}
|
||||
data-testid="platform-image-preview-stage"
|
||||
className={[
|
||||
'relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black',
|
||||
transform.scale > MIN_PLATFORM_IMAGE_PREVIEW_SCALE
|
||||
? isDragging
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
: 'cursor-default',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ touchAction: 'none' }}
|
||||
onWheel={handleWheel}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishPointerDrag}
|
||||
onPointerCancel={finishPointerDrag}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
refreshKey={refreshKey}
|
||||
alt={imageAlt}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget;
|
||||
setNaturalSize({
|
||||
width: image.naturalWidth || image.clientWidth || 1,
|
||||
height: image.naturalHeight || image.clientHeight || 1,
|
||||
});
|
||||
}}
|
||||
className={`max-w-none select-none object-contain transition-transform ease-out ${
|
||||
isDragging ? 'duration-0' : 'duration-150'
|
||||
}`}
|
||||
style={imageStyle}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
96
src/components/common/platformImagePreviewModel.ts
Normal file
96
src/components/common/platformImagePreviewModel.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export type PlatformImagePreviewSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type PlatformImagePreviewTransform = {
|
||||
scale: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
export const MIN_PLATFORM_IMAGE_PREVIEW_SCALE = 1;
|
||||
export const MAX_PLATFORM_IMAGE_PREVIEW_SCALE = 4;
|
||||
export const PLATFORM_IMAGE_PREVIEW_SCALE_STEP = 0.5;
|
||||
export const DEFAULT_PLATFORM_IMAGE_PREVIEW_TRANSFORM: PlatformImagePreviewTransform =
|
||||
{
|
||||
scale: MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
function clampNumber(value: number, min: number, max: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return min;
|
||||
}
|
||||
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function normalizePreviewSize(size: PlatformImagePreviewSize | null) {
|
||||
if (!size || size.width <= 0 || size.height <= 0) {
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export function resolvePlatformImagePreviewContainedSize(
|
||||
viewportSize: PlatformImagePreviewSize,
|
||||
naturalSize: PlatformImagePreviewSize | null,
|
||||
) {
|
||||
const viewport = normalizePreviewSize(viewportSize);
|
||||
const natural = normalizePreviewSize(naturalSize);
|
||||
const imageRatio = natural.width / natural.height;
|
||||
const viewportRatio = viewport.width / viewport.height;
|
||||
|
||||
if (imageRatio > viewportRatio) {
|
||||
return {
|
||||
width: viewport.width,
|
||||
height: viewport.width / imageRatio,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: viewport.height * imageRatio,
|
||||
height: viewport.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function samePlatformImagePreviewTransform(
|
||||
left: PlatformImagePreviewTransform,
|
||||
right: PlatformImagePreviewTransform,
|
||||
) {
|
||||
return (
|
||||
left.scale === right.scale &&
|
||||
left.offsetX === right.offsetX &&
|
||||
left.offsetY === right.offsetY
|
||||
);
|
||||
}
|
||||
|
||||
export function clampPlatformImagePreviewTransform(
|
||||
transform: PlatformImagePreviewTransform,
|
||||
viewportSize: PlatformImagePreviewSize,
|
||||
naturalSize: PlatformImagePreviewSize | null,
|
||||
): PlatformImagePreviewTransform {
|
||||
const viewport = normalizePreviewSize(viewportSize);
|
||||
const containedSize = resolvePlatformImagePreviewContainedSize(
|
||||
viewport,
|
||||
naturalSize,
|
||||
);
|
||||
const scale = clampNumber(
|
||||
transform.scale,
|
||||
MIN_PLATFORM_IMAGE_PREVIEW_SCALE,
|
||||
MAX_PLATFORM_IMAGE_PREVIEW_SCALE,
|
||||
);
|
||||
const scaledWidth = containedSize.width * scale;
|
||||
const scaledHeight = containedSize.height * scale;
|
||||
const panLimitX = Math.max(0, (scaledWidth - viewport.width) / 2);
|
||||
const panLimitY = Math.max(0, (scaledHeight - viewport.height) / 2);
|
||||
|
||||
return {
|
||||
scale,
|
||||
offsetX: clampNumber(transform.offsetX, -panLimitX, panLimitX),
|
||||
offsetY: clampNumber(transform.offsetY, -panLimitY, panLimitY),
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
resolveMiniGameGenerationProgressTickState,
|
||||
resolveMiniGameGenerationViewBusy,
|
||||
} from './PlatformEntryFlowShellImpl';
|
||||
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
|
||||
import {
|
||||
buildExternalGenerationQueuePresentation,
|
||||
buildExternalGenerationQueueStatus,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
|
||||
|
||||
describe('resolveMiniGameGenerationProgressTickState', () => {
|
||||
@@ -88,4 +91,36 @@ describe('buildExternalGenerationQueueStatus', () => {
|
||||
test('没有队列或任务信息时不显示状态条', () => {
|
||||
expect(buildExternalGenerationQueueStatus(null, null)).toBeNull();
|
||||
});
|
||||
|
||||
test('构造我的页生成队列展示状态', () => {
|
||||
expect(
|
||||
buildExternalGenerationQueuePresentation({
|
||||
currentStatus: 'running',
|
||||
currentProgress: 42.4,
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
statusLabel: '生成中',
|
||||
progressLabel: '42%',
|
||||
pendingLabel: '2',
|
||||
runningLabel: '1',
|
||||
pendingCount: 2,
|
||||
runningCount: 1,
|
||||
progress: 42,
|
||||
shouldShow: true,
|
||||
});
|
||||
|
||||
expect(buildExternalGenerationQueuePresentation(null).shouldShow).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
buildExternalGenerationQueuePresentation({
|
||||
currentStatus: 'completed',
|
||||
currentProgress: 100,
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
}).shouldShow,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4782,13 +4782,18 @@ export function PlatformEntryFlowShellImpl({
|
||||
woodenFishGenerationState,
|
||||
);
|
||||
const shouldShowExternalGenerationQueueStatus =
|
||||
isExternalGenerationQueueStage(selectionStage);
|
||||
isExternalGenerationQueueStage(selectionStage) ||
|
||||
(selectionStage === 'platform' &&
|
||||
platformBootstrap.platformTab === 'profile' &&
|
||||
platformBootstrap.canReadProtectedData);
|
||||
useEffect(() => {
|
||||
if (!shouldShowExternalGenerationQueueStatus) {
|
||||
setExternalGenerationQueueOverview(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setExternalGenerationQueueOverview(null);
|
||||
|
||||
let disposed = false;
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
@@ -4816,47 +4821,44 @@ export function PlatformEntryFlowShellImpl({
|
||||
controller?.abort();
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [shouldShowExternalGenerationQueueStatus]);
|
||||
const puzzleExternalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
puzzleOperation?.queueState ?? null,
|
||||
),
|
||||
[externalGenerationQueueOverview, puzzleOperation],
|
||||
);
|
||||
const jumpHopExternalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
jumpHopQueueState,
|
||||
),
|
||||
[externalGenerationQueueOverview, jumpHopQueueState],
|
||||
);
|
||||
const puzzleClearExternalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
puzzleClearQueueState,
|
||||
),
|
||||
[externalGenerationQueueOverview, puzzleClearQueueState],
|
||||
);
|
||||
const woodenFishExternalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
woodenFishQueueState,
|
||||
),
|
||||
[externalGenerationQueueOverview, woodenFishQueueState],
|
||||
);
|
||||
}, [authUi?.user?.id, shouldShowExternalGenerationQueueStatus]);
|
||||
const activeExternalGenerationJobState = useMemo(() => {
|
||||
const candidates = [
|
||||
puzzleOperation?.queueState ?? null,
|
||||
jumpHopQueueState,
|
||||
puzzleClearQueueState,
|
||||
woodenFishQueueState,
|
||||
].filter((candidate): candidate is ExternalGenerationJobStatusRecord =>
|
||||
Boolean(candidate),
|
||||
);
|
||||
|
||||
return (
|
||||
candidates.find(
|
||||
(candidate) =>
|
||||
candidate.status === 'queued' || candidate.status === 'running',
|
||||
) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}, [
|
||||
jumpHopQueueState,
|
||||
puzzleClearQueueState,
|
||||
puzzleOperation?.queueState,
|
||||
woodenFishQueueState,
|
||||
]);
|
||||
const externalGenerationQueueStatus = useMemo(
|
||||
() =>
|
||||
buildExternalGenerationQueueStatus(
|
||||
externalGenerationQueueOverview,
|
||||
null,
|
||||
activeExternalGenerationJobState,
|
||||
),
|
||||
[externalGenerationQueueOverview],
|
||||
[activeExternalGenerationJobState, externalGenerationQueueOverview],
|
||||
);
|
||||
const profileExternalGenerationQueueStatus =
|
||||
platformBootstrap.canReadProtectedData &&
|
||||
platformBootstrap.platformTab === 'profile'
|
||||
? externalGenerationQueueStatus
|
||||
: null;
|
||||
const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
|
||||
platformBootstrap.platformError,
|
||||
)
|
||||
@@ -15141,6 +15143,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
|
||||
hasUnreadDraftUpdate={hasUnreadDraftUpdates}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileExternalGenerationQueueStatus}
|
||||
isDesktopLayout={isDesktopLayout}
|
||||
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
|
||||
platformError={
|
||||
@@ -15576,7 +15579,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="草稿生成中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
queueStatus={jumpHopExternalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -15721,7 +15723,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('match3d-agent-workspace');
|
||||
}}
|
||||
onRetry={retryMatch3DDraftGeneration}
|
||||
queueStatus={puzzleClearExternalGenerationQueueStatus}
|
||||
hideBatchModule
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -15992,7 +15993,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="草稿生成中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
queueStatus={woodenFishExternalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16193,7 +16193,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="图片生成中"
|
||||
pausedBadgeLabel="图片生成已暂停"
|
||||
idleBadgeLabel="等待返回结果页"
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16398,7 +16397,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('jump-hop-workspace');
|
||||
}}
|
||||
onRetry={retryJumpHopDraftGeneration}
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16547,7 +16545,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="素材生成中"
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16677,7 +16674,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('wooden-fish-workspace');
|
||||
}}
|
||||
onRetry={retryWoodenFishDraftGeneration}
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -16871,7 +16867,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onRetry={retryPuzzleDraftGeneration}
|
||||
queueStatus={puzzleExternalGenerationQueueStatus}
|
||||
hideBatchModule
|
||||
/>
|
||||
</Suspense>
|
||||
@@ -16998,7 +16993,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="草稿生成中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -17242,7 +17236,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeBadgeLabel="草稿编译中"
|
||||
pausedBadgeLabel="草稿生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
queueStatus={externalGenerationQueueStatus}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformProgressBar } from '../common/PlatformProgressBar';
|
||||
import {
|
||||
buildExternalGenerationQueuePresentation,
|
||||
type ExternalGenerationQueueStatus,
|
||||
} from './platformExternalGenerationQueueStatusModel';
|
||||
|
||||
type PlatformProfileGenerationQueueCardProps = {
|
||||
queueStatus: ExternalGenerationQueueStatus | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* “我的”页里的后台生成状态卡。
|
||||
* 只展示当前账号可见的队列概览,业务完成仍以各玩法草稿 / 作品回读为准。
|
||||
*/
|
||||
export function PlatformProfileGenerationQueueCard({
|
||||
queueStatus,
|
||||
}: PlatformProfileGenerationQueueCardProps) {
|
||||
const presentation = buildExternalGenerationQueuePresentation(queueStatus);
|
||||
|
||||
if (!presentation.shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progressValue = presentation.progress ?? 0;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="platform-profile-generation-queue-card"
|
||||
aria-label="生成队列"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="platform-profile-generation-queue-card__icon">
|
||||
<Loader2 className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-[15px] font-black text-[var(--platform-text-strong)]">
|
||||
生成队列
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] font-medium text-[var(--platform-text-base)]">
|
||||
{presentation.statusLabel ?? '等待生成任务'}
|
||||
</div>
|
||||
</div>
|
||||
{presentation.statusLabel ? (
|
||||
<PlatformPillBadge
|
||||
tone={
|
||||
queueStatus?.currentStatus === 'failed' ? 'danger' : 'profile'
|
||||
}
|
||||
size="xs"
|
||||
className="shrink-0 px-2.5 py-1"
|
||||
>
|
||||
{presentation.progressLabel
|
||||
? `${presentation.statusLabel} ${presentation.progressLabel}`
|
||||
: presentation.statusLabel}
|
||||
</PlatformPillBadge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PlatformProgressBar
|
||||
value={progressValue}
|
||||
indeterminate={presentation.progress == null}
|
||||
ariaLabel="生成队列进度"
|
||||
size="sm"
|
||||
className="mt-3 bg-[rgba(240,226,214,0.88)]"
|
||||
fillClassName="bg-[linear-gradient(90deg,#e47631,#ce5f2a)]"
|
||||
/>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<div className="platform-profile-generation-queue-card__metric">
|
||||
<span>排队</span>
|
||||
<strong>{presentation.pendingLabel}</strong>
|
||||
</div>
|
||||
<div className="platform-profile-generation-queue-card__metric">
|
||||
<span>生成</span>
|
||||
<strong>{presentation.runningLabel}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,24 @@ import type {
|
||||
ExternalGenerationJobStatusRecord,
|
||||
ExternalGenerationQueueOverview,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type { ExternalGenerationQueueStatus } from '../CustomWorldGenerationView';
|
||||
|
||||
export type ExternalGenerationQueueStatus = {
|
||||
currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null;
|
||||
currentProgress?: number | null;
|
||||
pendingCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
};
|
||||
|
||||
export type ExternalGenerationQueuePresentation = {
|
||||
statusLabel: string | null;
|
||||
progressLabel: string | null;
|
||||
pendingLabel: string;
|
||||
runningLabel: string;
|
||||
pendingCount: number;
|
||||
runningCount: number;
|
||||
progress: number | null;
|
||||
shouldShow: boolean;
|
||||
};
|
||||
|
||||
export function buildExternalGenerationQueueStatus(
|
||||
overview: ExternalGenerationQueueOverview | null,
|
||||
@@ -19,3 +36,97 @@ export function buildExternalGenerationQueueStatus(
|
||||
runningCount: overview?.runningCount ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExternalGenerationQueueStatusLabel(
|
||||
status: ExternalGenerationQueueStatus['currentStatus'],
|
||||
) {
|
||||
if (status === 'queued') {
|
||||
return '排队中';
|
||||
}
|
||||
|
||||
if (status === 'running') {
|
||||
return '生成中';
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
return '生成失败';
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return '已完成';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExternalGenerationQueueOverviewLabel(
|
||||
pendingCount: number,
|
||||
runningCount: number,
|
||||
) {
|
||||
if (runningCount > 0) {
|
||||
return '生成中';
|
||||
}
|
||||
|
||||
if (pendingCount > 0) {
|
||||
return '排队中';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeExternalGenerationQueueCount(
|
||||
value: number | null | undefined,
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export function normalizeExternalGenerationQueueProgress(
|
||||
value: number | null | undefined,
|
||||
) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function buildExternalGenerationQueuePresentation(
|
||||
status: ExternalGenerationQueueStatus | null | undefined,
|
||||
): ExternalGenerationQueuePresentation {
|
||||
const pendingCount = normalizeExternalGenerationQueueCount(
|
||||
status?.pendingCount,
|
||||
);
|
||||
const runningCount = normalizeExternalGenerationQueueCount(
|
||||
status?.runningCount,
|
||||
);
|
||||
const progress = normalizeExternalGenerationQueueProgress(
|
||||
status?.currentProgress,
|
||||
);
|
||||
const isCompletedCurrentJob = status?.currentStatus === 'completed';
|
||||
const currentStatusLabel = resolveExternalGenerationQueueStatusLabel(
|
||||
status?.currentStatus ?? null,
|
||||
);
|
||||
const overviewStatusLabel = resolveExternalGenerationQueueOverviewLabel(
|
||||
pendingCount,
|
||||
runningCount,
|
||||
);
|
||||
const statusLabel = isCompletedCurrentJob
|
||||
? overviewStatusLabel
|
||||
: (currentStatusLabel ?? overviewStatusLabel);
|
||||
const progressValue = isCompletedCurrentJob ? null : progress;
|
||||
|
||||
return {
|
||||
statusLabel,
|
||||
progressLabel: progressValue == null ? null : `${progressValue}%`,
|
||||
pendingLabel: pendingCount.toString(),
|
||||
runningLabel: runningCount.toString(),
|
||||
pendingCount,
|
||||
runningCount,
|
||||
progress: progressValue,
|
||||
shouldShow: Boolean(statusLabel || pendingCount > 0 || runningCount > 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -779,6 +779,7 @@ function ProfileHomeViewHarness({
|
||||
userOverrides = {},
|
||||
activeTab = 'profile',
|
||||
profileTaskRefreshKey = 0,
|
||||
profileGenerationQueueStatus = null,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
}: {
|
||||
@@ -789,6 +790,7 @@ function ProfileHomeViewHarness({
|
||||
userOverrides?: Partial<AuthUser>;
|
||||
activeTab?: RpgEntryHomeViewProps['activeTab'];
|
||||
profileTaskRefreshKey?: number;
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
}) {
|
||||
@@ -856,6 +858,7 @@ function ProfileHomeViewHarness({
|
||||
onSearchPublicCode={vi.fn()}
|
||||
onRechargeSuccess={onRechargeSuccess}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={profileGenerationQueueStatus}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
@@ -871,6 +874,7 @@ function renderProfileView(
|
||||
profileStatsOptions: {
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
profileGenerationQueueStatus?: RpgEntryHomeViewProps['profileGenerationQueueStatus'];
|
||||
} = {},
|
||||
) {
|
||||
return render(
|
||||
@@ -879,6 +883,9 @@ function renderProfileView(
|
||||
profileDashboardOverrides={profileDashboardOverrides}
|
||||
userOverrides={userOverrides}
|
||||
profileTaskRefreshKey={profileTaskRefreshKey}
|
||||
profileGenerationQueueStatus={
|
||||
profileStatsOptions.profileGenerationQueueStatus
|
||||
}
|
||||
profilePlayStats={profileStatsOptions.profilePlayStats}
|
||||
isProfilePlayStatsOpen={profileStatsOptions.isProfilePlayStatsOpen}
|
||||
/>,
|
||||
@@ -2596,7 +2603,11 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
const taskTitle = await screen.findByText('每日登录');
|
||||
const taskPanel = taskTitle.closest('.platform-subpanel') as HTMLElement;
|
||||
const taskPanel = screen
|
||||
.getByRole('button', { name: '领取' })
|
||||
.closest('.rounded-\\[1rem\\]') as HTMLElement;
|
||||
expect(taskTitle).toBeTruthy();
|
||||
expect(taskPanel).toBeTruthy();
|
||||
expect(taskPanel.className).toContain('rounded-[1rem]');
|
||||
expect(taskPanel.className).toContain('p-4');
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
@@ -2892,6 +2903,46 @@ test('profile stats cards are centered without update timestamp', async () => {
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
test('profile page shows external generation queue status', async () => {
|
||||
renderProfileView(
|
||||
vi.fn(),
|
||||
{},
|
||||
{},
|
||||
0,
|
||||
{
|
||||
profileGenerationQueueStatus: {
|
||||
currentStatus: 'queued',
|
||||
currentProgress: 18,
|
||||
pendingCount: 6,
|
||||
runningCount: 2,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText('1 / 1');
|
||||
const queueRegion = screen.getByRole('region', { name: '生成队列' });
|
||||
expect(queueRegion.className).toContain(
|
||||
'platform-profile-generation-queue-card',
|
||||
);
|
||||
expect(within(queueRegion).getByText('排队中')).toBeTruthy();
|
||||
expect(within(queueRegion).getByText('排队中 18%')).toBeTruthy();
|
||||
expect(within(queueRegion).getByText('排队')).toBeTruthy();
|
||||
expect(within(queueRegion).getByText('6')).toBeTruthy();
|
||||
expect(within(queueRegion).getByText('生成')).toBeTruthy();
|
||||
expect(within(queueRegion).getByText('2')).toBeTruthy();
|
||||
const progressbar = within(queueRegion).getByRole('progressbar', {
|
||||
name: '生成队列进度',
|
||||
});
|
||||
expect(progressbar.getAttribute('aria-valuenow')).toBe('18');
|
||||
});
|
||||
|
||||
test('profile page hides external generation queue card without queue state', async () => {
|
||||
renderProfileView();
|
||||
|
||||
await screen.findByText('1 / 1');
|
||||
expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile profile page matches the reference layout sections', async () => {
|
||||
mockNarrowMobileLayout();
|
||||
|
||||
|
||||
@@ -125,6 +125,7 @@ import {
|
||||
ProfileStatCardSkeleton,
|
||||
} from '../platform-entry/PlatformProfilePrimitives';
|
||||
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
|
||||
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
|
||||
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
|
||||
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
|
||||
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
|
||||
@@ -132,6 +133,7 @@ import { PlatformProfileReferralModal } from '../platform-entry/PlatformProfileR
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
import { PlatformProfileTaskCenterModal } from '../platform-entry/PlatformProfileTaskCenterModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../platform-entry/PlatformProfileWalletLedgerModal';
|
||||
import type { ExternalGenerationQueueStatus } from '../platform-entry/platformExternalGenerationQueueStatusModel';
|
||||
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
|
||||
import {
|
||||
type RechargePaymentResult,
|
||||
@@ -274,6 +276,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenFeedback?: () => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
profileTaskRefreshKey?: number;
|
||||
profileGenerationQueueStatus?: ExternalGenerationQueueStatus | null;
|
||||
createTabContent?: ReactNode;
|
||||
draftTabContent?: ReactNode;
|
||||
hasUnreadDraftUpdate?: boolean;
|
||||
@@ -2568,6 +2571,7 @@ export function RpgEntryHomeView({
|
||||
onOpenFeedback,
|
||||
onRechargeSuccess,
|
||||
profileTaskRefreshKey = 0,
|
||||
profileGenerationQueueStatus = null,
|
||||
createTabContent,
|
||||
draftTabContent,
|
||||
hasUnreadDraftUpdate = false,
|
||||
@@ -4345,6 +4349,10 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
</button>
|
||||
|
||||
<PlatformProfileGenerationQueueCard
|
||||
queueStatus={profileGenerationQueueStatus}
|
||||
/>
|
||||
|
||||
<section
|
||||
className="platform-profile-shortcut-panel"
|
||||
aria-label="常用功能"
|
||||
|
||||
@@ -71,27 +71,21 @@ describe('UnifiedGenerationPage', () => {
|
||||
expect(screen.queryByText('云端糖果塔')).toBeNull();
|
||||
});
|
||||
|
||||
test('显示外部生成队列状态', () => {
|
||||
test('生成页不再显示外部生成队列状态', () => {
|
||||
render(
|
||||
<UnifiedGenerationPage
|
||||
playId="puzzle"
|
||||
settingText="一只发光的纸船"
|
||||
progress={createProgress()}
|
||||
isGenerating
|
||||
queueStatus={{
|
||||
currentStatus: 'queued',
|
||||
currentProgress: 18,
|
||||
pendingCount: 6,
|
||||
runningCount: 2,
|
||||
}}
|
||||
onBack={() => {}}
|
||||
onEditSetting={() => {}}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('排队中 18%')).toBeTruthy();
|
||||
expect(screen.getByText('排队 6')).toBeTruthy();
|
||||
expect(screen.getByText('生成 2')).toBeTruthy();
|
||||
expect(screen.queryByRole('region', { name: '生成队列' })).toBeNull();
|
||||
expect(screen.queryByText('排队中 18%')).toBeNull();
|
||||
expect(screen.queryByText('排队 6')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
import {
|
||||
CustomWorldGenerationView,
|
||||
type ExternalGenerationQueueStatus,
|
||||
} from '../CustomWorldGenerationView';
|
||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||
import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy';
|
||||
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
|
||||
|
||||
@@ -18,7 +15,6 @@ type UnifiedGenerationPageProps = {
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
hideBatchModule?: boolean;
|
||||
queueStatus?: ExternalGenerationQueueStatus | null;
|
||||
};
|
||||
|
||||
export function UnifiedGenerationPage({
|
||||
@@ -32,7 +28,6 @@ export function UnifiedGenerationPage({
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
hideBatchModule = false,
|
||||
queueStatus = null,
|
||||
}: UnifiedGenerationPageProps) {
|
||||
const copy = getUnifiedGenerationCopy(playId);
|
||||
|
||||
@@ -56,7 +51,6 @@ export function UnifiedGenerationPage({
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
hideBatchModule={hideBatchModule}
|
||||
queueStatus={queueStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6411,6 +6411,49 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
margin-bottom: -0.2rem;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card {
|
||||
width: 100%;
|
||||
min-height: 6.2rem;
|
||||
padding: 0.9rem 0.95rem;
|
||||
border: 1px solid rgba(235, 221, 208, 0.82);
|
||||
border-radius: 1.55rem;
|
||||
background: rgba(255, 250, 246, 0.92);
|
||||
box-shadow: 0 10px 28px rgba(112, 57, 30, 0.06);
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
flex: none;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 237, 222, 0.95);
|
||||
color: #bf673b;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 244, 235, 0.82);
|
||||
color: var(--platform-text-base);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__metric strong {
|
||||
color: var(--platform-text-strong);
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-panel {
|
||||
padding: 0.78rem 0.68rem 0.82rem;
|
||||
}
|
||||
@@ -6684,6 +6727,27 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card {
|
||||
min-height: 5.75rem;
|
||||
padding: 0.72rem 0.76rem;
|
||||
border-radius: 1.12rem;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__icon {
|
||||
width: 1.72rem;
|
||||
height: 1.72rem;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__metric {
|
||||
padding: 0.48rem 0.56rem;
|
||||
border-radius: 0.78rem;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.platform-profile-generation-queue-card__metric strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.platform-profile-shortcut-panel {
|
||||
padding: 0.64rem 0.54rem 0.68rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user