This commit is contained in:
2026-05-14 14:21:17 +08:00
parent 7a75f5d612
commit d33c937ebc
191 changed files with 1916 additions and 1549 deletions

View File

@@ -1,4 +1,4 @@
import {
import {
type ReactNode,
useDeferredValue,
useEffect,
@@ -1199,7 +1199,7 @@ export function CustomWorldEntityCatalog({
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="platform-pill platform-pill--warm px-2.5 py-1 text-[10px]">
</span>
) : null}
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">

View File

@@ -62,7 +62,7 @@ export function BindPhoneScreen({
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(255,79,139,0.18),rgba(255,155,120,0.14))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">

View File

@@ -0,0 +1,444 @@
import {
type CSSProperties,
type PointerEvent,
useMemo,
useRef,
useState,
} from 'react';
export type SquareImageCropRect = {
x: number;
y: number;
size: number;
};
export type SquareImageCropModalLabels = {
title: string;
close: string;
editor: string;
previewAlt: string;
cancel: string;
submit: string;
saving: string;
};
type SquareCropDragHandle =
| 'move'
| 'north'
| 'northEast'
| 'east'
| 'southEast'
| 'south'
| 'southWest'
| 'west'
| 'northWest';
type SquareCropDragSnapshot = {
pointerId: number;
handle: SquareCropDragHandle;
clientX: number;
clientY: number;
cropRect: SquareImageCropRect;
previewWidth: number;
previewHeight: number;
};
const SQUARE_CROP_RESIZE_HANDLES: Array<{
handle: Exclude<SquareCropDragHandle, '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 clampNumber(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
function getSquareCropSizeBounds(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 };
}
export function buildCenteredSquareImageCropRect(imageSize: {
width: number;
height: number;
}): SquareImageCropRect {
const size = Math.max(1, Math.min(imageSize.width, imageSize.height));
return {
x: Math.max(0, (imageSize.width - size) / 2),
y: Math.max(0, (imageSize.height - size) / 2),
size,
};
}
export function clampSquareImageCropRect(
imageSize: { width: number; height: number },
crop: SquareImageCropRect,
): SquareImageCropRect {
const { minSize, maxSize } = getSquareCropSizeBounds(imageSize);
const size = clampNumber(crop.size, minSize, maxSize);
return {
x: clampNumber(crop.x, 0, Math.max(0, imageSize.width - size)),
y: clampNumber(crop.y, 0, Math.max(0, imageSize.height - size)),
size,
};
}
function buildSquareCropPreviewStyle(
crop: SquareImageCropRect,
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 resizeSquareCropRectFromHandle(
snapshot: SquareCropDragSnapshot,
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 } = getSquareCropSizeBounds(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 clampSquareImageCropRect(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 clampSquareImageCropRect(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 clampSquareImageCropRect(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 clampSquareImageCropRect(imageSize, {
x: isEast ? anchorX : anchorX - size,
y: isSouth ? anchorY : anchorY - size,
size,
});
}
export function SquareImageCropModal({
source,
imageSize,
cropRect,
labels,
titleId = 'square-image-crop-title',
error = null,
isSaving = false,
onCropRectChange,
onClose,
onSubmit,
}: {
source: string;
imageSize: { width: number; height: number };
cropRect: SquareImageCropRect;
labels: SquareImageCropModalLabels;
titleId?: string;
error?: string | null;
isSaving?: boolean;
onCropRectChange: (nextCrop: SquareImageCropRect) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
const dragSnapshotRef = useRef<SquareCropDragSnapshot | null>(null);
const [activeDragHandle, setActiveDragHandle] =
useState<SquareCropDragHandle | null>(null);
const normalizedCropRect = useMemo(
() => clampSquareImageCropRect(imageSize, cropRect),
[cropRect, imageSize],
);
const previewStyle = useMemo(
() => buildSquareCropPreviewStyle(normalizedCropRect, imageSize),
[normalizedCropRect, imageSize],
);
const editorPreviewStyle = useMemo(
() =>
({
aspectRatio: `${imageSize.width} / ${imageSize.height}`,
width: `min(100%, calc(min(52vh, 22rem) * ${
imageSize.width / Math.max(1, imageSize.height)
}))`,
}) satisfies CSSProperties,
[imageSize],
);
const beginCropDrag = (
handle: SquareCropDragHandle,
event: PointerEvent<HTMLElement>,
) => {
if (isSaving) {
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: normalizedCropRect,
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);
onCropRectChange(
resizeSquareCropRectFromHandle(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 (
<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={titleId}
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id={titleId} className="text-base font-black">
{labels.title}
</div>
<button
type="button"
aria-label={labels.close}
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="relative mx-auto overflow-hidden rounded-[1.2rem] border border-white/12 bg-black/35 select-none touch-none"
style={editorPreviewStyle}
aria-label={labels.editor}
>
<img
src={source}
alt={labels.previewAlt}
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}>
{SQUARE_CROP_RESIZE_HANDLES.map((handleConfig) => (
<button
key={handleConfig.handle}
type="button"
aria-label={handleConfig.label}
disabled={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)
}
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>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
{labels.cancel}
</button>
<button
type="button"
onClick={onSubmit}
disabled={isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? labels.saving : labels.submit}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -382,7 +382,7 @@ export function CreativeAgentHome({
<CreativeAgentInputComposer
variant="floating"
isBusy={isBusy}
placeholder="问一问百梦"
placeholder="问一问陶泥儿"
onSubmit={(payload) => {
const content = buildCreativeHomeInputParts(payload);
if (content.length === 0) {

View File

@@ -40,7 +40,7 @@ test('shows cost range and opens an independent adjustment dialog', () => {
);
const confirmDialog = screen.getByRole('dialog', { name: '确认拼图模板' });
expect(within(confirmDialog).getByText('预计 2 到 12 点')).toBeTruthy();
expect(within(confirmDialog).getByText('预计 2 到 12 点')).toBeTruthy();
expect(within(confirmDialog).getByText('创意拼图')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: //u }));

View File

@@ -61,7 +61,7 @@ export function CreativeAgentTemplateConfirmPanel({
setDraftSelection(selection);
}, [selection]);
const pointsText = `${draftSelection.costRange.minPoints}${draftSelection.costRange.maxPoints} `;
const pointsText = `${draftSelection.costRange.minPoints}${draftSelection.costRange.maxPoints} `;
const panel = (
<div

View File

@@ -119,7 +119,7 @@ test('target ready session exposes the puzzle result entry action', () => {
expect(screen.getByText('拼图草稿已就绪')).toBeTruthy();
expect(screen.getByText('可以进入结果页继续编辑')).toBeTruthy();
expect(screen.getByText('预计 2-12 点')).toBeTruthy();
expect(screen.getByText('预计 2-12 点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开草稿' }));
@@ -168,7 +168,7 @@ test('waiting confirmation shows template catalog before template config dialog'
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '确认拼图模板' })).toBeTruthy();
expect(screen.getByText('预计 4 到 16 点')).toBeTruthy();
expect(screen.getByText('预计 4 到 16 点')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: //u }));

View File

@@ -85,7 +85,7 @@ function CreativeAgentTemplateCatalogPanel({
{template.summary}
</div>
<div className="mt-3 text-xs font-bold text-[var(--platform-text-soft)]">
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} `}
{`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} `}
</div>
</button>
))}

View File

@@ -323,7 +323,7 @@ function buildEventProcessItem(
return {
id: `${index}-cost-${event.data.costRange.minPoints}-${event.data.costRange.maxPoints}`,
meta: '消耗',
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} `,
title: `预计 ${event.data.costRange.minPoints}-${event.data.costRange.maxPoints} `,
detail: event.data.costRange.reason,
detailLines: [],
tone: 'info',
@@ -482,7 +482,7 @@ function buildSessionFallbackItems(
id: `session-level-plan-${plan.templateId}`,
meta: '关卡',
title: `规划 ${plan.levels.length} 个关卡`,
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} `,
detail: `${formatPuzzleLevelMode(plan.mode)} · ${plan.estimatedCostRange.minPoints}-${plan.estimatedCostRange.maxPoints} `,
detailLines: plan.levels.slice(0, 4).map((level) =>
[
level.levelName,

View File

@@ -345,9 +345,9 @@ test('creation hub shows puzzle point incentive and claims without opening card'
profileId: 'puzzle-profile-incentive',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '百梦灯塔',
levelName: '陶泥儿灯塔',
summary: '拼图作品会展示积分激励。',
themeTags: ['灯塔', '百梦'],
themeTags: ['灯塔', '陶泥儿'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-01T12:00:00.000Z').toISOString(),
@@ -375,8 +375,8 @@ test('creation hub shows puzzle point incentive and claims without opening card'
/>,
);
expect(screen.getByLabelText('积分激励总数 2.5 点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 点')).toBeTruthy();
expect(screen.getByLabelText('积分激励总数 2.5 点')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 点')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));

View File

@@ -255,7 +255,7 @@ export function CustomWorldWorkCard({
event.preventDefault();
onOpen();
}}
className={`platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
className={`creation-work-card platform-surface platform-interactive-card relative min-h-[9.5rem] cursor-pointer overflow-hidden px-2.5 py-2.5 text-left sm:min-h-[12.5rem] sm:px-4 sm:py-4 xl:min-h-[11.25rem] xl:px-4 xl:py-3.5 ${isPublished ? 'col-span-2 sm:col-span-1' : ''}`}
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
@@ -263,10 +263,9 @@ export function CustomWorldWorkCard({
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0 opacity-70 saturate-[1.08]"
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
<div className="creation-work-card__overlay absolute inset-0" />
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
@@ -288,7 +287,7 @@ export function CustomWorldWorkCard({
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
className="grid h-7 w-7 place-items-center rounded-full bg-black/22 text-white/78 transition hover:bg-red-500/22 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
@@ -326,7 +325,7 @@ export function CustomWorldWorkCard({
? '分享内容复制失败'
: '分享'
}
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap rounded-full bg-black/22 px-1.5 text-white/78 transition hover:bg-white/18 hover:text-white disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
>
{shareState === 'idle' ? (
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
@@ -358,10 +357,10 @@ export function CustomWorldWorkCard({
</div>
<div className="mt-3 min-h-0 sm:mt-4 xl:mt-3">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl xl:text-xl">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.52)] sm:text-2xl xl:text-xl">
{displayTitle}
</div>
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
<div className="mt-2 line-clamp-2 break-words text-[11px] leading-4 text-white/84 [text-shadow:0_1px_8px_rgba(0,0,0,0.5)] sm:mt-3 sm:text-sm sm:leading-6 xl:mt-2">
{item.summary}
</div>
</div>
@@ -371,7 +370,7 @@ export function CustomWorldWorkCard({
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} `}
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} `}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
@@ -384,7 +383,7 @@ export function CustomWorldWorkCard({
</span>
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} `}
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} `}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">

View File

@@ -187,6 +187,109 @@ test('buildCreationWorkShelfItems sorts works by latest updatedAt across timesta
]);
});
test('buildCreationWorkShelfItems falls back to available gameplay images as covers', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:level-cover',
profileId: 'puzzle-profile-level-cover',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '关卡封面拼图',
summary: '作品自身封面为空时使用关卡正式图。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '港口雨夜。',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle-candidate.png',
assetId: 'asset-1',
prompt: '港口雨夜',
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'ready',
},
],
},
],
match3dItems: [
{
workId: 'match3d:asset-cover',
profileId: 'match3d-profile-asset-cover',
ownerUserId: 'user-1',
gameName: '素材封面抓鹅',
themeText: '糖果厨房',
summary: '作品自身封面为空时使用素材图。',
tags: [],
coverImageSrc: null,
clearCount: 18,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-07T00:00:00.000Z',
publishReady: false,
generatedItemAssets: [
{
itemId: 'item-1',
itemName: '糖果',
imageSrc: '/match3d-item.png',
status: 'image_ready',
},
],
},
],
squareHoleItems: [
{
workId: 'square-hole:background-cover',
profileId: 'square-hole-profile-background-cover',
ownerUserId: 'user-1',
gameName: '背景封面方洞',
themeText: '星空玩具箱',
twistRule: '旋转洞口',
summary: '作品自身封面为空时使用背景图。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '星空玩具箱',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: [],
holeOptions: [],
shapeCount: 3,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-06T00:00:00.000Z',
publishReady: false,
},
],
});
expect(items.find((item) => item.kind === 'puzzle')?.coverImageSrc).toBe(
'/puzzle-candidate.png',
);
expect(items.find((item) => item.kind === 'match3d')?.coverImageSrc).toBe(
'/match3d-item.png',
);
expect(items.find((item) => item.kind === 'square-hole')?.coverImageSrc).toBe(
'/square-hole-background.png',
);
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

View File

@@ -364,6 +364,7 @@ function mapMatch3DWorkToShelfItem(
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildMatch3DPublicWorkCode(item.profileId) : null;
const coverImageSrc = resolveMatch3DWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -372,7 +373,7 @@ function mapMatch3DWorkToShelfItem(
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -408,6 +409,7 @@ function mapPuzzleWorkToShelfItem(
const status = item.publicationStatus;
const publicWorkCode =
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null;
const coverImageSrc = resolvePuzzleWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -419,7 +421,7 @@ function mapPuzzleWorkToShelfItem(
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -571,6 +573,7 @@ function mapSquareHoleWorkToShelfItem(
status === 'published'
? buildSquareHolePublicWorkCode(item.profileId)
: null;
const coverImageSrc = resolveSquareHoleWorkCoverImageSrc(item);
return {
id: item.workId,
@@ -579,7 +582,7 @@ function mapSquareHoleWorkToShelfItem(
title: item.gameName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
@@ -607,6 +610,90 @@ function mapSquareHoleWorkToShelfItem(
};
}
function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
for (const level of item.levels ?? []) {
const selectedCandidateImageSrc =
level.selectedCandidateId && level.candidates.length > 0
? normalizeCoverImageSrc(
level.candidates.find(
(candidate) => candidate.candidateId === level.selectedCandidateId,
)?.imageSrc,
)
: null;
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const levelCoverImageSrc =
selectedCandidateImageSrc ||
normalizeCoverImageSrc(level.coverImageSrc) ||
fallbackCandidateImageSrc;
if (levelCoverImageSrc) {
return levelCoverImageSrc;
}
}
return null;
}
function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
const backgroundImageSrc =
normalizeCoverImageSrc(item.backgroundImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.imageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const asset of item.generatedItemAssets ?? []) {
const imageViewSrc = normalizeCoverImageSrc(
asset.imageViews?.find((view) => normalizeCoverImageSrc(view.imageSrc))
?.imageSrc,
);
const itemImageSrc = normalizeCoverImageSrc(asset.imageSrc);
if (imageViewSrc || itemImageSrc) {
return imageViewSrc || itemImageSrc;
}
}
return null;
}
function resolveSquareHoleWorkCoverImageSrc(item: SquareHoleWorkSummary) {
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
if (directCoverImageSrc) {
return directCoverImageSrc;
}
const backgroundImageSrc = normalizeCoverImageSrc(item.backgroundImageSrc);
if (backgroundImageSrc) {
return backgroundImageSrc;
}
for (const option of [...item.shapeOptions, ...item.holeOptions]) {
const optionImageSrc = normalizeCoverImageSrc(option.imageSrc);
if (optionImageSrc) {
return optionImageSrc;
}
}
return null;
}
function buildWorkShelfActions<TItem>(
item: TItem,
adapter: WorkShelfAdapter<TItem>,

View File

@@ -75,7 +75,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
expect(screen.getByText('2D素材风格')).toBeTruthy();
expect(screen.getByRole('button', { name: '扁平图标' })).toBeTruthy();
expect(screen.getByRole('button', { name: '自定义' })).toBeTruthy();
expect(screen.getByText('消耗10点')).toBeTruthy();
expect(screen.getByText('消耗10点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '生成音效' })).toBeNull();
expect(screen.queryByText('参考图')).toBeNull();
expect(screen.queryByLabelText('上传抓大鹅参考图')).toBeNull();

View File

@@ -480,7 +480,7 @@ export function Match3DAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
10
10
</span>
</span>
</button>

View File

@@ -504,7 +504,7 @@ describe('Match3DResultView', () => {
expect(screen.getByRole('dialog', { name: /水果核心物件/u })).toBeTruthy();
expect(screen.getByText('素材名称')).toBeTruthy();
expect(screen.getByText('暂无音效')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10点')).toBeTruthy();
expect(screen.getByLabelText('生成点击音效10点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重新生成' })).toBeNull();
expect(screen.queryByText('用途')).toBeNull();
});
@@ -638,7 +638,7 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('物品名称 4'), {
target: { value: '苹果' },
});
expect(screen.getByRole('button', { name: /生成物品素材 · 2点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /生成物品素材 · 2点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成物品素材/u }));
await waitFor(() => {
@@ -1109,7 +1109,7 @@ describe('Match3DResultView', () => {
fireEvent.change(screen.getByLabelText('UI背景图画面描述提示词'), {
target: { value: '新背景提示词' },
});
expect(screen.getByRole('button', { name: /重新生成 · 2点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /重新生成 · 2点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /重新生成/u }));
await waitFor(() => {
@@ -1369,7 +1369,7 @@ describe('Match3DResultView', () => {
'轻快, 休闲',
);
expect(screen.queryByLabelText('抓大鹅背景音乐提示词')).toBeNull();
expect(screen.getByRole('button', { name: /生成音乐 · 5点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /生成音乐 · 5点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成音乐/u }));

View File

@@ -1,4 +1,4 @@
import {
import {
ArrowLeft,
CheckCircle2,
Eye,
@@ -1952,8 +1952,8 @@ function Match3DItemAssetDetail({
disabled={busy || soundBusy}
onClick={() => onGenerateClickSound(asset)}
className={`platform-icon-button h-9 w-9 ${busy || soundBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}`}
aria-label={`生成点击音效,${MATCH3D_CLICK_SOUND_POINTS_COST}`}
title={`生成点击音效 · ${MATCH3D_CLICK_SOUND_POINTS_COST}`}
>
{soundBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -2154,7 +2154,7 @@ function Match3DBatchAddItemsPanel({
) : (
<Plus className="h-4 w-4" />
)}
· {pointsCost}
· {pointsCost}
</button>
</div>
</Match3DModalShell>
@@ -2332,7 +2332,7 @@ function Match3DMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}
</button>
</section>
@@ -2451,7 +2451,7 @@ function Match3DUIAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
· {MATCH3D_UI_BACKGROUND_POINTS_COST}
</button>
</div>
</div>

View File

@@ -1003,7 +1003,7 @@ function escapePuzzleOnboardingSvgText(value: string) {
function buildPuzzleOnboardingFallbackImage(promptText: string) {
const trimmedPrompt = promptText.trim();
const displayPrompt = escapePuzzleOnboardingSvgText(
trimmedPrompt.slice(0, 12) || '百梦拼图',
trimmedPrompt.slice(0, 12) || '陶泥儿拼图',
);
return (
'data:image/svg+xml;utf8,' +
@@ -1065,7 +1065,7 @@ function buildPuzzleOnboardingFallbackWork(
profileId: `onboarding-local-profile-${seed}`,
ownerUserId: 'onboarding-guest',
sourceSessionId: null,
authorDisplayName: '百梦主',
authorDisplayName: '陶泥儿主',
workTitle: '梦境拼图',
workDescription: promptText,
levelName: level.levelName,
@@ -9731,7 +9731,7 @@ export function PlatformEntryFlowShellImpl({
}
setPublicSearchError(
resolveRpgCreationErrorMessage(error, '未找到对应的百梦号或作品号。'),
resolveRpgCreationErrorMessage(error, '未找到对应的陶泥号或作品号。'),
);
} finally {
setIsSearchingPublicCode(false);
@@ -12233,7 +12233,7 @@ export function PlatformEntryFlowShellImpl({
{searchedPublicUser.displayName}
</div>
<div className="mt-2 text-sm text-[var(--platform-text-soft)]">
{searchedPublicUser.publicUserCode}
{searchedPublicUser.publicUserCode}
</div>
</div>
) : null}

View File

@@ -66,7 +66,7 @@ function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
profileId: 'baby-object-match-profile-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '百梦主',
authorDisplayName: '陶泥儿主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',

View File

@@ -181,7 +181,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
imageModel: 'gpt-image-2',
aiRedraw: true,
});
expect(screen.getByText('消耗2点')).toBeTruthy();
expect(screen.getByText('消耗2点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
@@ -498,7 +498,7 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
expect((aiRedrawSwitch as HTMLInputElement).checked).toBe(true);
fireEvent.click(aiRedrawSwitch);
expect(screen.queryByLabelText('画面AI重绘要求提示词')).toBeNull();
expect(screen.queryByText('消耗2点')).toBeNull();
expect(screen.queryByText('消耗2点')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /稿/u }));

View File

@@ -8,8 +8,6 @@ import {
} from 'lucide-react';
import {
type ChangeEvent,
type CSSProperties,
type PointerEvent,
useEffect,
useMemo,
useRef,
@@ -27,6 +25,12 @@ import {
isPuzzleReferenceImageSquare,
readPuzzleReferenceImageForUpload,
} from '../../services/puzzleReferenceImage';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
import PuzzleHistoryAssetPickerDialog from './PuzzleHistoryAssetPickerDialog';
import {
normalizePuzzleImageModel,
@@ -69,81 +73,11 @@ type PuzzleImageCropState = {
source: string;
label: string;
imageSize: { width: number; height: number };
cropX: number;
cropY: number;
cropSize: number;
cropRect: SquareImageCropRect;
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,
@@ -202,324 +136,6 @@ function resolveInitialFormState(
};
}
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 },
crop: { x: number; y: number; size: number },
) {
const { minSize, maxSize } = getPuzzleCropSizeBounds(imageSize);
const size = clampNumber(crop.size, minSize, maxSize);
return {
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,
onCropRectChange,
onClose,
onSubmit,
}: {
state: PuzzleImageCropState;
onCropRectChange: (nextCrop: { x: number; y: number; size: number }) => void;
onClose: () => void;
onSubmit: () => void;
}) {
const previewRef = useRef<HTMLDivElement | null>(null);
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 (!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) * 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 (
<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-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="puzzle-image-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭拼图图片裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
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)
}
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 ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{state.error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={state.isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{state.isSaving ? '裁剪中' : '应用'}
</button>
</div>
</div>
</div>
</div>
);
}
/**
* 拼图创作入口已从 Agent 对话改为填表式。
* 组件名保留为 PuzzleAgentWorkspace 以兼容现有路由与草稿恢复入口。
@@ -654,17 +270,15 @@ export function PuzzleAgentWorkspace({
try {
const uploadImage = await readPuzzleReferenceImageForUpload(file);
if (!isPuzzleReferenceImageSquare(uploadImage)) {
const cropSize = Math.min(uploadImage.width, uploadImage.height);
const imageSize = {
width: uploadImage.width,
height: uploadImage.height,
};
setCropState({
source: uploadImage.dataUrl,
label: file.name.trim() || '本地拼图图片',
imageSize: {
width: uploadImage.width,
height: uploadImage.height,
},
cropX: Math.max(0, (uploadImage.width - cropSize) / 2),
cropY: Math.max(0, (uploadImage.height - cropSize) / 2),
cropSize,
imageSize,
cropRect: buildCenteredSquareImageCropRect(imageSize),
error: null,
isSaving: false,
});
@@ -693,12 +307,10 @@ export function PuzzleAgentWorkspace({
if (!current) {
return current;
}
const clamped = clampPuzzleImageCropRect(current.imageSize, nextCrop);
const clamped = clampSquareImageCropRect(current.imageSize, nextCrop);
return {
...current,
cropX: clamped.x,
cropY: clamped.y,
cropSize: clamped.size,
cropRect: clamped,
};
});
};
@@ -718,9 +330,9 @@ export function PuzzleAgentWorkspace({
try {
const dataUrl = await cropPuzzleReferenceImageDataUrl({
source: currentCropState.source,
cropX: currentCropState.cropX,
cropY: currentCropState.cropY,
cropSize: currentCropState.cropSize,
cropX: currentCropState.cropRect.x,
cropY: currentCropState.cropRect.y,
cropSize: currentCropState.cropRect.size,
});
setFormState((current) => ({
...current,
@@ -1003,15 +615,29 @@ export function PuzzleAgentWorkspace({
<span>稿</span>
{formState.aiRedraw ? (
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
2
2
</span>
) : null}
</span>
</button>
</div>
{cropState ? (
<PuzzleImageCropModal
state={cropState}
<SquareImageCropModal
source={cropState.source}
imageSize={cropState.imageSize}
cropRect={cropState.cropRect}
titleId="puzzle-image-crop-title"
labels={{
title: '裁剪拼图图片',
close: '关闭拼图图片裁剪',
editor: '拼图图片裁剪操作区',
previewAlt: '拼图图片裁剪预览',
cancel: '取消',
submit: '应用',
saving: '裁剪中',
}}
error={cropState.error}
isSaving={cropState.isSaving}
onCropRectChange={updateCropState}
onClose={() => setCropState(null)}
onSubmit={() => {

View File

@@ -288,9 +288,9 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗点',
name: '确认消耗点',
});
expect(within(confirmDialog).getByText('消耗 2 点')).toBeTruthy();
expect(within(confirmDialog).getByText('消耗 2 点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
@@ -371,7 +371,7 @@ describe('PuzzleResultView', () => {
expect(
within(dialog).getByRole('button', { name: /生成画面/u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2点')).toBeTruthy();
expect(within(dialog).getByText('消耗2点')).toBeTruthy();
expect(
within(dialog).getByText('等待时间可以制作更多关卡哦~'),
).toBeTruthy();
@@ -434,7 +434,7 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -481,7 +481,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -517,7 +517,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -604,7 +604,7 @@ describe('PuzzleResultView', () => {
rerender(
<PuzzleResultView
session={createSession()}
error="点余额不足"
error="点余额不足"
isBusy={false}
onBack={() => {}}
onExecuteAction={onExecuteAction}
@@ -615,7 +615,7 @@ describe('PuzzleResultView', () => {
name: '发布拼图作品',
});
expect(publishDialog).toBeTruthy();
expect(within(publishDialog).getByText('点余额不足')).toBeTruthy();
expect(within(publishDialog).getByText('点余额不足')).toBeTruthy();
});
test('generates six tags after work title and description are filled', () => {
@@ -730,7 +730,7 @@ describe('PuzzleResultView', () => {
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
target: { value: '新拼图UI背景提示词' },
});
expect(screen.getByRole('button', { name: /生成UI背景 · 2点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /生成UI背景 · 2点/u })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
expect(onExecuteAction).toHaveBeenCalledWith({
@@ -785,7 +785,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
fireEvent.click(screen.getByRole('button', { name: '背景音乐' }));
expect(screen.getByRole('button', { name: /重新生成音乐 · 5点/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /重新生成音乐 · 5点/u })).toBeTruthy();
expect(screen.getByLabelText('拼图背景音乐').getAttribute('src')).toBe(
'https://signed.example.com/generated-puzzle-assets/session/audio/music.mp3',
@@ -965,7 +965,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -1011,7 +1011,7 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),
@@ -1048,7 +1048,7 @@ describe('PuzzleResultView', () => {
within(dialog).getByRole('button', { name: /重新生成画面/u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
within(screen.getByRole('dialog', { name: '确认消耗点' })).getByRole(
'button',
{ name: '确定' },
),

View File

@@ -955,7 +955,7 @@ function PuzzleLevelDetailDialog({
<Sparkles className="h-4 w-4" />
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</span>
</span>
<span className="text-[11px] font-semibold leading-none text-white/78">
@@ -973,7 +973,7 @@ function PuzzleLevelDetailDialog({
<section
role="dialog"
aria-modal="true"
aria-label="确认消耗点"
aria-label="确认消耗点"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
onClick={(event) => event.stopPropagation()}
>
@@ -982,11 +982,11 @@ function PuzzleLevelDetailDialog({
<Sparkles className="h-4 w-4" />
</span>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
</div>
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
{PUZZLE_IMAGE_GENERATION_POINT_COST}
{PUZZLE_IMAGE_GENERATION_POINT_COST}
</div>
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<button
@@ -1508,7 +1508,7 @@ function PuzzleUiAssetsTab({
) : (
<Wand2 className="h-4 w-4" />
)}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
{firstLevel?.uiBackgroundImageSrc ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}
</button>
</div>
</div>
@@ -1809,7 +1809,7 @@ function PuzzleMusicTab({
) : (
<Music className="h-4 w-4" />
)}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
{currentMusic ? '重新生成音乐' : '生成音乐'} · {PUZZLE_BACKGROUND_MUSIC_POINT_COST}
</button>
</section>

View File

@@ -1184,7 +1184,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
fireEvent.click(screen.getByRole('button', { name: '提示' }));
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
await act(async () => {
@@ -1201,7 +1201,7 @@ test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', as
test('道具使用失败时保留确认弹窗和暂停态', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -1234,7 +1234,7 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
});
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(onPauseChange).toHaveBeenLastCalledWith(true);
});
@@ -1386,7 +1386,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
expect(screen.getByText('消耗 1 点')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
@@ -1396,7 +1396,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const onUseProp = vi.fn().mockRejectedValue(new Error('点余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
@@ -1428,7 +1428,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
expect(screen.getByText('点余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {

View File

@@ -2007,7 +2007,7 @@ export function PuzzleRuntimeShell({
</h2>
</header>
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
1
1
{propConfirmError ? (
<div className="puzzle-runtime-error-chip mt-3 rounded-[0.9rem] border px-3 py-2 text-xs leading-5">
{propConfirmError}

View File

@@ -219,7 +219,7 @@ export function RpgCreationRoleAnimationSection(props: {
<ActionButton
icon={<RefreshCcw className="h-4 w-4" />}
label={isSelectedAnimationGenerating ? '生成中...' : '生成动作'}
subLabel={`消耗${animationPointCost}`}
subLabel={`消耗${animationPointCost}`}
onClick={onGenerateAnimation}
disabled={
isSelectedAnimationGenerating ||

View File

@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 点。\n${params.description}`,
`${params.kindLabel}预计消耗 ${params.points} 点。\n${params.description}`,
);
};

View File

@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
? '重新生成角色形象'
: '生成角色形象'
}
subLabel={`消耗${visualPointCost}`}
subLabel={`消耗${visualPointCost}`}
onClick={onGenerateVisuals}
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
tone="sky"

View File

@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '百梦 GENARRATIVE'}
aria-label={decorative ? undefined : '陶泥儿 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);

View File

@@ -2547,7 +2547,7 @@ beforeEach(() => {
profileId: 'onboarding-profile-1',
ownerUserId: 'onboarding-guest',
sourceSessionId: null,
authorDisplayName: '百梦主',
authorDisplayName: '陶泥儿主',
workTitle: '梦境拼图',
workDescription: '我想飞上天',
levelName: '云上飞行',
@@ -2811,7 +2811,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(screen.getByRole('tab', { name: //u })).toBeTruthy();

View File

@@ -149,14 +149,14 @@ const {
pointProducts: [
{
productId: 'points_60',
title: '60点',
title: '60点',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60点',
description: '首充送60点',
tier: 'normal',
},
],
@@ -176,7 +176,7 @@ const {
],
benefits: [
{
benefitName: '免点回合数',
benefitName: '免点回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
@@ -191,7 +191,7 @@ const {
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60点',
productTitle: '60点',
kind: 'points',
amountCents: 600,
status: 'paid',
@@ -335,6 +335,38 @@ function dispatchPointerEvent(
target.dispatchEvent(event);
}
function stubImage(width = 800, height = 600) {
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = width;
naturalHeight = height;
width = width;
height = height;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
}
function stubFileReader(dataUrl: string) {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = dataUrl;
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
}
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -826,6 +858,7 @@ afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
@@ -901,9 +934,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('点账单')).toBeTruthy();
expect(await screen.findByText('点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
expect(screen.getByText('资产操作消耗')).toBeTruthy();
expect(screen.getByText('-1')).toBeTruthy();
@@ -923,7 +956,7 @@ test('profile recharge modal buys points through mock channel outside mini progr
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: /60/u }));
await user.click(screen.getByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
@@ -953,7 +986,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
order: {
orderId: 'order-wechat-1',
productId: 'points_60',
productTitle: '60点',
productTitle: '60点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
@@ -993,7 +1026,7 @@ test('profile recharge modal posts requestPayment params in mini program web-vie
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
await user.click(await screen.findByRole('button', { name: /60/u }));
await user.click(await screen.findByRole('button', { name: /60/u }));
await waitFor(() => {
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
@@ -1029,7 +1062,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login');
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已领取 10 点')).toBeTruthy();
expect(await screen.findByText('已领取 10 点')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
.disabled,
@@ -1073,17 +1106,42 @@ test('desktop account entry uses saved avatar image when available', () => {
expect(within(accountEntry).queryByText('测')).toBeNull();
});
test('profile avatar upload uses the shared square crop tool', async () => {
stubFileReader('data:image/png;base64,avatar-source');
stubImage(800, 600);
renderProfileView();
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
fireEvent.change(screen.getByLabelText('上传头像', { selector: 'input' }), {
target: {
files: [new File(['x'], 'avatar.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy();
});
expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy();
expect(
screen.getByRole('button', { name: '拖拽右下角裁剪边界' }),
).toBeTruthy();
expect(screen.queryByText('缩放')).toBeNull();
expect(screen.queryByText('横向')).toBeNull();
expect(screen.queryByText('纵向')).toBeNull();
});
test('wallet ledger modal shows empty and error states', async () => {
const user = userEvent.setup();
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭点账单'));
await user.click(screen.getByLabelText('关闭点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -1104,7 +1162,7 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('邀请一个用户注册双方都可以获得30点。'),
await screen.findByText('邀请一个用户注册双方都可以获得30点。'),
).toBeTruthy();
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
expect(screen.getByText('成功邀请')).toBeTruthy();

View File

@@ -88,6 +88,12 @@ import {
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
@@ -2286,201 +2292,9 @@ function ProfileNicknameModal({
);
}
function ProfileAvatarCropModal({
source,
imageSize,
scale,
cropX,
cropY,
error,
isSaving,
onScaleChange,
onCropChange,
onClose,
onSubmit,
}: {
source: string;
imageSize: { width: number; height: number };
scale: number;
cropX: number;
cropY: number;
error: string | null;
isSaving: boolean;
onScaleChange: (value: number) => void;
onCropChange: (nextCrop: { x: number; y: 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(imageSize.width, imageSize.height) / scale;
const maxCropX = Math.max(0, imageSize.width - cropSize);
const maxCropY = Math.max(0, imageSize.height - cropSize);
const backgroundSize = `${(imageSize.width / cropSize) * 100}% ${(imageSize.height / cropSize) * 100}%`;
const backgroundPosition = `${maxCropX > 0 ? (cropX / maxCropX) * 100 : 50}% ${maxCropY > 0 ? (cropY / maxCropY) * 100 : 50}%`;
const updateDragCrop = (event: PointerEvent<HTMLDivElement>) => {
const dragStart = dragStartRef.current;
const preview = previewRef.current;
if (!dragStart || !preview || event.pointerId !== dragStart.pointerId) {
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,
});
};
const stopDragging = (event: PointerEvent<HTMLDivElement>) => {
if (dragStartRef.current?.pointerId === event.pointerId) {
dragStartRef.current = null;
setIsDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<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="profile-avatar-crop-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div id="profile-avatar-crop-title" className="text-base font-black">
</div>
<button
type="button"
aria-label="关闭头像裁剪"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<div
ref={previewRef}
className="mx-auto aspect-square w-full max-w-[16rem] overflow-hidden rounded-[1.4rem] border border-white/12 bg-cover bg-center"
style={{
backgroundImage: `url("${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,
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={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(cropX, maxCropX)}
onChange={(event) =>
onCropChange({ x: Number(event.target.value), y: cropY })
}
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(cropY, maxCropY)}
onChange={(event) =>
onCropChange({ x: cropX, y: Number(event.target.value) })
}
className="w-full"
/>
</label>
</div>
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? '上传中' : '上传'}
</button>
</div>
</div>
</div>
</div>
);
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
new_user_registration_reward: '注册赠送',
points_recharge: '点充值',
points_recharge: '点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
@@ -2587,7 +2401,7 @@ function RechargeProductCard({
const submitting = submittingProductId === product.productId;
const value =
product.kind === 'points'
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}`
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}`
: `${product.durationDays}`;
return (
@@ -2662,7 +2476,7 @@ function ProfileRechargeModal({
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{center
? `${center.walletBalance}点 · ${memberLabel}`
? `${center.walletBalance}点 · ${memberLabel}`
: '读取中'}
</div>
</div>
@@ -2682,7 +2496,7 @@ function ProfileRechargeModal({
onClick={() => onTabChange('points')}
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
>
</button>
<button
type="button"
@@ -2767,7 +2581,7 @@ function WalletLedgerModal({
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭点账单"
aria-label="关闭点账单"
>
×
</button>
@@ -2776,10 +2590,10 @@ function WalletLedgerModal({
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
<span>{balance}</span>
</div>
</div>
@@ -2889,7 +2703,7 @@ function ProfileTaskCenterModal({
<div>
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{walletBalance}
{walletBalance}
</div>
</div>
<button
@@ -3180,7 +2994,7 @@ function ProfileReferralModal({
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm font-semibold leading-6 text-amber-900">
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}点。`}
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}点。`}
</div>
<div></div>
</div>
@@ -3522,8 +3336,11 @@ export function RpgEntryHomeView({
width: number;
height: number;
} | null>(null);
const [avatarScale, setAvatarScale] = useState(1);
const [avatarCrop, setAvatarCrop] = useState({ x: 0, y: 0 });
const [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
x: 0,
y: 0,
size: 1,
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
@@ -3634,9 +3451,6 @@ export function RpgEntryHomeView({
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const avatarCropSize = avatarImageSize
? Math.min(avatarImageSize.width, avatarImageSize.height) / avatarScale
: 0;
const remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
const totalPlayTime = formatTotalPlayTimeHours(
profileDashboard?.totalPlayTimeMs ?? 0,
@@ -3889,14 +3703,9 @@ export function RpgEntryHomeView({
void loadAvatarFile(file)
.then(async (source) => {
const imageSize = await readImageIntrinsicSize(source);
const cropSize = Math.min(imageSize.width, imageSize.height);
setAvatarSource(source);
setAvatarImageSize(imageSize);
setAvatarScale(1);
setAvatarCrop({
x: Math.max(0, (imageSize.width - cropSize) / 2),
y: Math.max(0, (imageSize.height - cropSize) / 2),
});
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
})
.catch((error: unknown) => {
setAvatarError(
@@ -3904,54 +3713,21 @@ export function RpgEntryHomeView({
);
});
};
const updateAvatarScale = useCallback(
(nextScale: number) => {
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {
return;
}
const normalizedScale = Math.min(3, Math.max(1, nextScale));
const nextCropSize =
Math.min(avatarImageSize.width, avatarImageSize.height) /
normalizedScale;
setAvatarScale(normalizedScale);
setAvatarCrop((current) => ({
x: Math.min(
current.x,
Math.max(0, avatarImageSize.width - nextCropSize),
),
y: Math.min(
current.y,
Math.max(0, avatarImageSize.height - nextCropSize),
),
}));
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
},
[avatarImageSize],
);
const updateAvatarCrop = useCallback(
(nextCrop: { x: number; y: number }) => {
if (!avatarImageSize || avatarCropSize <= 0) {
return;
}
setAvatarCrop({
x: Math.min(
Math.max(0, nextCrop.x),
Math.max(0, avatarImageSize.width - avatarCropSize),
),
y: Math.min(
Math.max(0, nextCrop.y),
Math.max(0, avatarImageSize.height - avatarCropSize),
),
});
},
[avatarCropSize, avatarImageSize],
);
const submitAvatar = () => {
if (
!avatarSource ||
!avatarImageSize ||
avatarCropSize <= 0 ||
avatarCrop.size <= 0 ||
isSavingAvatar
) {
return;
@@ -3963,7 +3739,7 @@ export function RpgEntryHomeView({
source: avatarSource,
cropX: avatarCrop.x,
cropY: avatarCrop.y,
cropSize: avatarCropSize,
cropSize: avatarCrop.size,
})
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
.then((nextUser) => {
@@ -3984,7 +3760,7 @@ export function RpgEntryHomeView({
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取点账单失败',
error instanceof Error ? error.message : '读取点账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
@@ -4206,7 +3982,7 @@ export function RpgEntryHomeView({
void redeemRpgProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} `);
setRewardCodeSuccess(`已到账 ${response.amountGranted} `);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -4225,7 +4001,7 @@ export function RpgEntryHomeView({
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} `);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} `);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
@@ -5240,6 +5016,7 @@ export function RpgEntryHomeView({
<input
ref={avatarFileInputRef}
type="file"
aria-label="上传头像"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) =>
@@ -5262,7 +5039,7 @@ export function RpgEntryHomeView({
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {publicUserCode}</span>
<span> {publicUserCode}</span>
<button
type="button"
onClick={copyProfilePublicUserCode}
@@ -5287,7 +5064,7 @@ export function RpgEntryHomeView({
<Coins className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] opacity-80">/</div>
<div className="text-[10px] opacity-80">/</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -5306,7 +5083,7 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="点"
label="点"
value="暂不可用"
icon={Coins}
onClick={openWalletLedgerPanel}
@@ -5330,7 +5107,7 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="点"
label="点"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={openWalletLedgerPanel}
@@ -5377,7 +5154,7 @@ export function RpgEntryHomeView({
/>
<ProfileShortcutButton
label="充值"
subLabel="点/会员"
subLabel="点/会员"
icon={Coins}
onClick={openRechargeModal}
/>
@@ -5793,16 +5570,23 @@ export function RpgEntryHomeView({
/>
) : null}
{avatarSource && avatarImageSize ? (
<ProfileAvatarCropModal
<SquareImageCropModal
source={avatarSource}
imageSize={avatarImageSize}
scale={avatarScale}
cropX={avatarCrop.x}
cropY={avatarCrop.y}
cropRect={avatarCrop}
titleId="profile-avatar-crop-title"
labels={{
title: '裁剪头像',
close: '关闭头像裁剪',
editor: '头像裁剪操作区',
previewAlt: '头像裁剪预览',
cancel: '取消',
submit: '上传',
saving: '上传中',
}}
error={avatarError}
isSaving={isSavingAvatar}
onScaleChange={updateAvatarScale}
onCropChange={updateAvatarCrop}
onCropRectChange={updateAvatarCrop}
onClose={() => {
setAvatarSource(null);
setAvatarImageSize(null);

View File

@@ -148,7 +148,7 @@ test('keeps baby object match public card code and template label intact', () =>
sourceSessionId: 'baby-object-match-session-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '百梦主',
authorDisplayName: '陶泥儿主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',

View File

@@ -397,7 +397,7 @@ export function mapBabyObjectMatchDraftToPlatformGalleryCard(
sourceSessionId: draft.draftId,
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
ownerUserId: 'current-user',
authorDisplayName: '百梦主',
authorDisplayName: '陶泥儿主',
worldName: draft.workTitle.trim() || draft.templateName,
subtitle: draft.templateName,
summaryText:

View File

@@ -63,7 +63,7 @@ test('visual novel workspace only exposes one-line input and visual style entry'
.querySelector('img')
?.getAttribute('src'),
).toBe('/visual-novel-style-references/dark-gothic.png');
expect(screen.getByText('消耗20点')).toBeTruthy();
expect(screen.getByText('消耗20点')).toBeTruthy();
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
expect(screen.queryByRole('button', { name: '文档' })).toBeNull();
expect(screen.queryByRole('button', { name: '空白' })).toBeNull();

View File

@@ -340,7 +340,7 @@ export function VisualNovelAgentWorkspace({
)}
<span>稿</span>
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
20
20
</span>
</span>
</button>