1
This commit is contained in:
@@ -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]">
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
444
src/components/common/SquareImageCropModal.tsx
Normal file
444
src/components/common/SquareImageCropModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export function CreativeAgentHome({
|
||||
<CreativeAgentInputComposer
|
||||
variant="floating"
|
||||
isBusy={isBusy}
|
||||
placeholder="问一问百梦"
|
||||
placeholder="问一问陶泥儿"
|
||||
onSubmit={(payload) => {
|
||||
const content = buildCreativeHomeInputParts(payload);
|
||||
if (content.length === 0) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '领取积分' }));
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: '将物品放入对应的篮子里。',
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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: '确定' },
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -790,7 +790,7 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
return window.confirm(
|
||||
`${params.kindLabel}预计消耗 ${params.points} 光点。\n${params.description}`,
|
||||
`${params.kindLabel}预计消耗 ${params.points} 泥点。\n${params.description}`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export function RpgCreationRoleVisualSection(props: {
|
||||
? '重新生成角色形象'
|
||||
: '生成角色形象'
|
||||
}
|
||||
subLabel={`消耗${visualPointCost}光点`}
|
||||
subLabel={`消耗${visualPointCost}泥点`}
|
||||
onClick={onGenerateVisuals}
|
||||
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
|
||||
tone="sky"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '将物品放入对应的篮子里。',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user