diff --git a/src/components/common/SquareImageCropModal.tsx b/src/components/common/SquareImageCropModal.tsx
new file mode 100644
index 00000000..50dce5f1
--- /dev/null
+++ b/src/components/common/SquareImageCropModal.tsx
@@ -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;
+ 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(null);
+ const dragSnapshotRef = useRef(null);
+ const [activeDragHandle, setActiveDragHandle] =
+ useState(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,
+ ) => {
+ 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) => {
+ 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) => {
+ if (dragSnapshotRef.current?.pointerId !== event.pointerId) {
+ return;
+ }
+
+ dragSnapshotRef.current = null;
+ setActiveDragHandle(null);
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ };
+
+ return (
+
+
+
+
+ {labels.title}
+
+
+
+
+
+

+
beginCropDrag('move', event)}
+ onPointerMove={updateCropDrag}
+ onPointerUp={stopCropDrag}
+ onPointerCancel={stopCropDrag}
+ />
+
+
+ {SQUARE_CROP_RESIZE_HANDLES.map((handleConfig) => (
+
+ ))}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/creative-agent/CreativeAgentHome.tsx b/src/components/creative-agent/CreativeAgentHome.tsx
index c2e08612..6aaada3a 100644
--- a/src/components/creative-agent/CreativeAgentHome.tsx
+++ b/src/components/creative-agent/CreativeAgentHome.tsx
@@ -382,7 +382,7 @@ export function CreativeAgentHome({
{
const content = buildCreativeHomeInputParts(payload);
if (content.length === 0) {
diff --git a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx
index b2f9b43a..197e9b4d 100644
--- a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx
+++ b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx
@@ -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 }));
diff --git a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx
index dfd91ad3..d1223fe9 100644
--- a/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx
+++ b/src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx
@@ -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 = (
{
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 }));
diff --git a/src/components/creative-agent/CreativeAgentWorkspace.tsx b/src/components/creative-agent/CreativeAgentWorkspace.tsx
index 42914e0a..918da8cb 100644
--- a/src/components/creative-agent/CreativeAgentWorkspace.tsx
+++ b/src/components/creative-agent/CreativeAgentWorkspace.tsx
@@ -85,7 +85,7 @@ function CreativeAgentTemplateCatalogPanel({
{template.summary}
- {`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 光点`}
+ {`${template.defaultLevelCount} 关 · ${template.costRange.minPoints}-${template.costRange.maxPoints} 泥点`}
))}
diff --git a/src/components/creative-agent/creativeAgentViewModel.ts b/src/components/creative-agent/creativeAgentViewModel.ts
index 39f57bfa..9362e26d 100644
--- a/src/components/creative-agent/creativeAgentViewModel.ts
+++ b/src/components/creative-agent/creativeAgentViewModel.ts
@@ -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,
diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
index 2c91ebbd..348922da 100644
--- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
+++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx
@@ -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: '领取积分' }));
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx
index 5fedfead..f41750d0 100644
--- a/src/components/custom-world-home/CustomWorldWorkCard.tsx
+++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx
@@ -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' : ''}`}
>
-
-
+
{item.hasUnreadUpdate ? (
{deleteBusy ? (
…
@@ -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' ? (
@@ -358,10 +357,10 @@ export function CustomWorldWorkCard({
-
+
{displayTitle}
-
@@ -371,7 +370,7 @@ export function CustomWorldWorkCard({
{item.pointIncentive ? (
@@ -384,7 +383,7 @@ export function CustomWorldWorkCard({
diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts
index 801bae34..97cf1b63 100644
--- a/src/components/custom-world-home/creationWorkShelf.test.ts
+++ b/src/components/custom-world-home/creationWorkShelf.test.ts
@@ -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,
diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts
index 82cd0023..78b9f296 100644
--- a/src/components/custom-world-home/creationWorkShelf.ts
+++ b/src/components/custom-world-home/creationWorkShelf.ts
@@ -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(
item: TItem,
adapter: WorkShelfAdapter,
diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx
index 149d47b7..b1967fb7 100644
--- a/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx
+++ b/src/components/match3d-creation/Match3DAgentWorkspace.interaction.test.tsx
@@ -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();
diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.tsx
index 9305e4ea..4d5443ec 100644
--- a/src/components/match3d-creation/Match3DAgentWorkspace.tsx
+++ b/src/components/match3d-creation/Match3DAgentWorkspace.tsx
@@ -480,7 +480,7 @@ export function Match3DAgentWorkspace({
)}
生成抓大鹅草稿
- 消耗10光点
+ 消耗10泥点
diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx
index 154436f1..c51ff527 100644
--- a/src/components/match3d-result/Match3DResultView.test.tsx
+++ b/src/components/match3d-result/Match3DResultView.test.tsx
@@ -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 }));
diff --git a/src/components/match3d-result/Match3DResultView.tsx b/src/components/match3d-result/Match3DResultView.tsx
index 49ddd34f..ac47dbf5 100644
--- a/src/components/match3d-result/Match3DResultView.tsx
+++ b/src/components/match3d-result/Match3DResultView.tsx
@@ -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 ? (
@@ -2154,7 +2154,7 @@ function Match3DBatchAddItemsPanel({
) : (
)}
- 生成物品素材 · {pointsCost}光点
+ 生成物品素材 · {pointsCost}泥点
@@ -2332,7 +2332,7 @@ function Match3DMusicTab({
) : (
)}
- {currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}光点
+ {currentMusic ? '重新生成音乐' : '生成音乐'} · {MATCH3D_BACKGROUND_MUSIC_POINTS_COST}泥点
@@ -2451,7 +2451,7 @@ function Match3DUIAssetsTab({
) : (
)}
- 重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}光点
+ 重新生成 · {MATCH3D_UI_BACKGROUND_POINTS_COST}泥点
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index 9ac3859e..6f7d9f4c 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
@@ -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}
- 百梦号 {searchedPublicUser.publicUserCode}
+ 陶泥号 {searchedPublicUser.publicUserCode}
) : null}
diff --git a/src/components/platform-entry/PlatformWorkDetailView.test.tsx b/src/components/platform-entry/PlatformWorkDetailView.test.tsx
index 581403c9..fe82e782 100644
--- a/src/components/platform-entry/PlatformWorkDetailView.test.tsx
+++ b/src/components/platform-entry/PlatformWorkDetailView.test.tsx
@@ -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: '将物品放入对应的篮子里。',
diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx
index 33d2a4c1..c3ee9496 100644
--- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx
+++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx
@@ -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 }));
diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
index 9f5d16c8..8e54b826 100644
--- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
+++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx
@@ -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