1
This commit is contained in:
@@ -1099,7 +1099,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]">
|
||||
|
||||
@@ -22,6 +22,7 @@ const baseUser: AuthUser = {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function renderAccountModal(overrides?: {
|
||||
|
||||
@@ -86,6 +86,7 @@ const mockUser: AuthUser = {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -209,9 +209,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(),
|
||||
@@ -237,8 +237,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: '领取积分' }));
|
||||
|
||||
|
||||
@@ -358,7 +358,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">
|
||||
@@ -371,7 +371,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">
|
||||
|
||||
@@ -306,6 +306,22 @@ function mapPuzzleWorkToPublicWorkDetail(
|
||||
return mapPuzzleWorkToPlatformGalleryCard(item);
|
||||
}
|
||||
|
||||
function resolveVisiblePuzzleDetailCoverCount(
|
||||
entry: PlatformPublicGalleryCard | null,
|
||||
run: PuzzleRunSnapshot | null,
|
||||
) {
|
||||
if (!entry || !isPuzzleGalleryEntry(entry)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (run?.entryProfileId !== entry.profileId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 中文注释:封面首图永远公开,后续封面跟随当前玩家本次 run 的通关进度即时解锁。
|
||||
return Math.max(1, run.clearedLevelCount + 1);
|
||||
}
|
||||
|
||||
function mapMatch3DWorkToPublicWorkDetail(
|
||||
item: Match3DWorkSummary,
|
||||
): PlatformPublicGalleryCard {
|
||||
@@ -3696,7 +3712,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSearchedPublicUser(user);
|
||||
} catch (error) {
|
||||
setPublicSearchError(
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的陶泥号或作品号。'),
|
||||
resolveRpgCreationErrorMessage(error, '未找到对应的百梦号或作品号。'),
|
||||
);
|
||||
} finally {
|
||||
setIsSearchingPublicCode(false);
|
||||
@@ -4162,6 +4178,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
isMatch3DBusy
|
||||
}
|
||||
error={publicWorkDetailError}
|
||||
visibleCoverCount={resolveVisiblePuzzleDetailCoverCount(
|
||||
selectedPublicWorkDetail,
|
||||
puzzleRun,
|
||||
)}
|
||||
onBack={() => {
|
||||
setPublicWorkDetailError(null);
|
||||
clearSelectedPublicWorkAuthor();
|
||||
@@ -5120,7 +5140,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}
|
||||
|
||||
@@ -124,7 +124,7 @@ test('PlatformWorkDetailView calls like handler', () => {
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
const { container } = render(
|
||||
<PlatformWorkDetailView
|
||||
entry={{
|
||||
...createPuzzleEntry(),
|
||||
@@ -154,12 +154,23 @@ test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-1.png',
|
||||
);
|
||||
const appIconImage = container.querySelector(
|
||||
'.platform-work-detail__app-icon img',
|
||||
);
|
||||
expect(appIconImage?.getAttribute('src')).toBe('/level-1.png');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));
|
||||
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-2.png',
|
||||
);
|
||||
expect(appIconImage?.getAttribute('src')).toBe('/level-1.png');
|
||||
expect(
|
||||
container.querySelector('.platform-work-detail__cover-image--locked'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-work-detail__cover-lock-icon'),
|
||||
).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4200);
|
||||
@@ -169,3 +180,44 @@ test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
'/level-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView unlocks later puzzle covers by visible cover count', () => {
|
||||
const { container } = render(
|
||||
<PlatformWorkDetailView
|
||||
entry={{
|
||||
...createPuzzleEntry(),
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'level-1',
|
||||
imageSrc: '/level-1.png',
|
||||
label: '第一关',
|
||||
},
|
||||
{
|
||||
id: 'level-2',
|
||||
imageSrc: '/level-2.png',
|
||||
label: '第二关',
|
||||
},
|
||||
],
|
||||
}}
|
||||
visibleCoverCount={2}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));
|
||||
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-2.png',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.platform-work-detail__cover-image--locked'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('.platform-work-detail__cover-lock-icon'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
Copy,
|
||||
Gamepad2,
|
||||
@@ -32,6 +33,7 @@ export interface PlatformWorkDetailViewProps {
|
||||
authorDisplayName?: string | null;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
visibleCoverCount?: number;
|
||||
onBack: () => void;
|
||||
onLike: () => void;
|
||||
onStart: () => void;
|
||||
@@ -71,6 +73,7 @@ export function PlatformWorkDetailView({
|
||||
authorDisplayName,
|
||||
isBusy,
|
||||
error,
|
||||
visibleCoverCount = 1,
|
||||
onBack,
|
||||
onLike,
|
||||
onStart,
|
||||
@@ -84,6 +87,9 @@ export function PlatformWorkDetailView({
|
||||
const activeCoverSlide =
|
||||
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
|
||||
const coverImage = activeCoverSlide?.imageSrc ?? '';
|
||||
const unlockedCoverCount = Math.max(1, Math.floor(visibleCoverCount));
|
||||
const isActiveCoverVisible = activeCoverIndex < unlockedCoverCount;
|
||||
const appIconImage = coverSlides[0]?.imageSrc ?? '';
|
||||
const hasCoverCarousel = coverSlides.length > 1;
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
@@ -237,8 +243,20 @@ export function PlatformWorkDetailView({
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="platform-work-detail__cover-image"
|
||||
className={`platform-work-detail__cover-image${
|
||||
isActiveCoverVisible
|
||||
? ''
|
||||
: ' platform-work-detail__cover-image--locked'
|
||||
}`}
|
||||
/>
|
||||
{!isActiveCoverVisible ? (
|
||||
<div
|
||||
className="platform-work-detail__cover-lock"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CircleHelp className="platform-work-detail__cover-lock-icon" />
|
||||
</div>
|
||||
) : null}
|
||||
{hasCoverCarousel ? (
|
||||
<>
|
||||
<button
|
||||
@@ -288,9 +306,9 @@ export function PlatformWorkDetailView({
|
||||
<section className="platform-work-detail__summary">
|
||||
<div className="platform-work-detail__meta-row">
|
||||
<div className="platform-work-detail__app-icon">
|
||||
{coverImage ? (
|
||||
{appIconImage ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
src={appIconImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -6,11 +6,12 @@ import { expect, test, vi } from 'vitest';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PuzzleRuntimeShell,
|
||||
buildMergedGroupOutlinePath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
} from './PuzzleRuntimeShell';
|
||||
} from './puzzleRuntimeShape';
|
||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: (src: string | null) => ({
|
||||
@@ -450,16 +451,44 @@ test('合并块按实际拼块外轮廓描边', () => {
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(outlinedPieces[0]?.className).toContain('border-r-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('border-b-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-br-[0.35rem]');
|
||||
expect(outlinedPieces[1]?.className).toContain('border-l-0');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-bl-[0.35rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('border-t-0');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-tr-[0.35rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
|
||||
expect(
|
||||
container.querySelector('[data-merged-group-outline="true"]'),
|
||||
).toBeTruthy();
|
||||
const outlineStroke = container.querySelector(
|
||||
'[data-merged-group-outline-stroke="true"]',
|
||||
);
|
||||
expect(outlineStroke).toBeTruthy();
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(outlineStroke?.getAttribute('d')).toContain('Q 1 1 1 1.16');
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clippedLayer?.style.clipPath).toContain('url(#');
|
||||
});
|
||||
|
||||
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
const outlinePath = buildMergedGroupOutlinePath({
|
||||
rowSpan: 2,
|
||||
colSpan: 2,
|
||||
pieces: [
|
||||
{
|
||||
localRow: 0,
|
||||
localCol: 0,
|
||||
},
|
||||
{
|
||||
localRow: 0,
|
||||
localCol: 1,
|
||||
},
|
||||
{
|
||||
localRow: 1,
|
||||
localCol: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(outlinePath).toContain('Q 2 1 1.84 1');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.16');
|
||||
});
|
||||
|
||||
test('基础单块使用圆角裁剪图片', () => {
|
||||
@@ -634,7 +663,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 () => {
|
||||
@@ -651,7 +680,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: {
|
||||
@@ -684,7 +713,7 @@ test('道具使用失败时保留确认弹窗和暂停态', async () => {
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
|
||||
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
|
||||
expect(screen.getByText('光点余额不足')).toBeTruthy();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
@@ -836,7 +865,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: '确定' }));
|
||||
@@ -846,7 +875,7 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
|
||||
});
|
||||
|
||||
test('失败续时扣费失败时保留确认弹窗', async () => {
|
||||
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
|
||||
const onUseProp = vi.fn().mockRejectedValue(new Error('光点余额不足'));
|
||||
const failedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -878,7 +907,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
|
||||
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
|
||||
expect(screen.getByText('光点余额不足')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Sparkles,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
@@ -27,6 +27,14 @@ import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildMergedGroupClipPath,
|
||||
buildMergedGroupOutlinePath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
sanitizeSvgId,
|
||||
} from './puzzleRuntimeShape';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
run: PuzzleRunSnapshot | null;
|
||||
@@ -85,127 +93,6 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
export function resolveDraggedPieceCellLayer(
|
||||
pieceId: string | null | undefined,
|
||||
draggingPieceId: string | null,
|
||||
isMerged: boolean,
|
||||
) {
|
||||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||||
return undefined;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
export function resolveDraggedPieceLayer(
|
||||
pieceId: string | null | undefined,
|
||||
draggingPieceId: string | null,
|
||||
isMerged: boolean,
|
||||
) {
|
||||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||||
return undefined;
|
||||
}
|
||||
return 81;
|
||||
}
|
||||
|
||||
export function resolveDraggedMergedGroupLayer(
|
||||
groupId: string,
|
||||
draggingGroupId: string | null,
|
||||
) {
|
||||
return groupId === draggingGroupId ? 90 : undefined;
|
||||
}
|
||||
|
||||
function resolveMergedPieceOutlineClass(
|
||||
group: PuzzleMergedGroupViewModel,
|
||||
piece: PuzzleMergedGroupViewModel['pieces'][number],
|
||||
) {
|
||||
const groupCellKeys = new Set(
|
||||
group.pieces.map((groupPiece) =>
|
||||
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
|
||||
),
|
||||
);
|
||||
const hasCell = (row: number, col: number) =>
|
||||
groupCellKeys.has(buildLocalCellKey(row, col));
|
||||
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
|
||||
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
|
||||
const hasBottomBoundary = (row: number, col: number) =>
|
||||
!hasCell(row + 1, col);
|
||||
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
|
||||
const hasTopEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow - 1, piece.localCol),
|
||||
);
|
||||
const hasRightEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol + 1),
|
||||
);
|
||||
const hasBottomEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow + 1, piece.localCol),
|
||||
);
|
||||
const hasLeftEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol - 1),
|
||||
);
|
||||
const topLeftRadius =
|
||||
hasTopEdge && hasLeftEdge
|
||||
? 'rounded-tl-[0.85rem]'
|
||||
: (!hasTopEdge && !hasLeftEdge) ||
|
||||
(hasTopEdge &&
|
||||
!hasLeftEdge &&
|
||||
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
|
||||
(hasLeftEdge &&
|
||||
!hasTopEdge &&
|
||||
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
|
||||
? 'rounded-tl-[0.35rem]'
|
||||
: 'rounded-tl-none';
|
||||
const topRightRadius =
|
||||
hasTopEdge && hasRightEdge
|
||||
? 'rounded-tr-[0.85rem]'
|
||||
: (!hasTopEdge && !hasRightEdge) ||
|
||||
(hasTopEdge &&
|
||||
!hasRightEdge &&
|
||||
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
|
||||
(hasRightEdge &&
|
||||
!hasTopEdge &&
|
||||
!hasRightBoundary(piece.localRow - 1, piece.localCol))
|
||||
? 'rounded-tr-[0.35rem]'
|
||||
: 'rounded-tr-none';
|
||||
const bottomRightRadius =
|
||||
hasBottomEdge && hasRightEdge
|
||||
? 'rounded-br-[0.85rem]'
|
||||
: (!hasBottomEdge && !hasRightEdge) ||
|
||||
(hasBottomEdge &&
|
||||
!hasRightEdge &&
|
||||
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
|
||||
(hasRightEdge &&
|
||||
!hasBottomEdge &&
|
||||
!hasRightBoundary(piece.localRow + 1, piece.localCol))
|
||||
? 'rounded-br-[0.35rem]'
|
||||
: 'rounded-br-none';
|
||||
const bottomLeftRadius =
|
||||
hasBottomEdge && hasLeftEdge
|
||||
? 'rounded-bl-[0.85rem]'
|
||||
: (!hasBottomEdge && !hasLeftEdge) ||
|
||||
(hasBottomEdge &&
|
||||
!hasLeftEdge &&
|
||||
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
|
||||
(hasLeftEdge &&
|
||||
!hasBottomEdge &&
|
||||
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
|
||||
? 'rounded-bl-[0.35rem]'
|
||||
: 'rounded-bl-none';
|
||||
return [
|
||||
hasTopEdge ? 'border-t-2' : 'border-t-0',
|
||||
hasRightEdge ? 'border-r-2' : 'border-r-0',
|
||||
hasBottomEdge ? 'border-b-2' : 'border-b-0',
|
||||
hasLeftEdge ? 'border-l-2' : 'border-l-0',
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
bottomRightRadius,
|
||||
bottomLeftRadius,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
groups: PuzzleMergedGroupState[],
|
||||
pieces: PuzzleBoardPieceViewModel[],
|
||||
@@ -372,6 +259,7 @@ export function PuzzleRuntimeShell({
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
@@ -1315,100 +1203,150 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => (
|
||||
<div
|
||||
key={group.groupId}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
groupElementRefMap.current.set(group.groupId, node);
|
||||
return;
|
||||
}
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
data-merged-group-id={group.groupId}
|
||||
className="pointer-events-none absolute z-10"
|
||||
style={{
|
||||
zIndex: resolveDraggedMergedGroupLayer(
|
||||
group.groupId,
|
||||
draggingGroupId,
|
||||
),
|
||||
transform: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
|
||||
: undefined,
|
||||
transition: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
left: `${(group.minCol / board.cols) * 100}%`,
|
||||
top: `${(group.minRow / board.rows) * 100}%`,
|
||||
width: `${(group.colSpan / board.cols) * 100}%`,
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{mergedGroups.map((group) => {
|
||||
const outlinePath = buildMergedGroupOutlinePath(group);
|
||||
const clipPath = buildMergedGroupClipPath(group);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none relative grid h-full w-full touch-none overflow-visible active:scale-[0.992]"
|
||||
key={group.groupId}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
groupElementRefMap.current.set(group.groupId, node);
|
||||
return;
|
||||
}
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
data-merged-group-id={group.groupId}
|
||||
className="pointer-events-none absolute z-10"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
zIndex: resolveDraggedMergedGroupLayer(
|
||||
group.groupId,
|
||||
draggingGroupId,
|
||||
),
|
||||
transform: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
|
||||
: undefined,
|
||||
transition: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
left: `${(group.minCol / board.cols) * 100}%`,
|
||||
top: `${(group.minRow / board.rows) * 100}%`,
|
||||
width: `${(group.colSpan / board.cols) * 100}%`,
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
group,
|
||||
piece,
|
||||
)}`}
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
{outlinePath ? (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-20 h-full w-full overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
||||
backgroundPosition: `${
|
||||
board.cols > 1
|
||||
? (piece.correctCol / (board.cols - 1)) * 100
|
||||
: 0
|
||||
}% ${
|
||||
board.rows > 1
|
||||
? (piece.correctRow / (board.rows - 1)) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/8" />
|
||||
</div>
|
||||
))}
|
||||
<defs>
|
||||
<clipPath
|
||||
clipPathUnits="objectBoundingBox"
|
||||
id={`${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)}`}
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d={clipPath}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d={outlinePath}
|
||||
data-merged-group-outline="true"
|
||||
fill="rgba(52, 211, 153, 0.08)"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d={outlinePath}
|
||||
data-merged-group-outline-stroke="true"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.22)"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
) : null}
|
||||
<div
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
|
||||
style={{
|
||||
WebkitClipPath: outlinePath
|
||||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)})`
|
||||
: undefined,
|
||||
clipPath: outlinePath
|
||||
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
|
||||
group.groupId,
|
||||
)})`
|
||||
: undefined,
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)]"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
||||
backgroundPosition: `${
|
||||
board.cols > 1
|
||||
? (piece.correctCol / (board.cols - 1)) * 100
|
||||
: 0
|
||||
}% ${
|
||||
board.rows > 1
|
||||
? (piece.correctRow / (board.rows - 1)) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{isOriginalOverlayVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-overlay"
|
||||
@@ -1567,7 +1505,7 @@ export function PuzzleRuntimeShell({
|
||||
</h2>
|
||||
</header>
|
||||
<div className="px-5 py-4 text-sm text-white/72">
|
||||
消耗 1 陶泥币
|
||||
消耗 1 光点
|
||||
{propConfirmError ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
|
||||
{propConfirmError}
|
||||
|
||||
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal file
276
src/components/puzzle-runtime/puzzleRuntimeShape.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
type PuzzleMergedGroupShape = {
|
||||
colSpan: number;
|
||||
rowSpan: number;
|
||||
pieces: Array<{
|
||||
localRow: number;
|
||||
localCol: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type GridPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type GridEdge = {
|
||||
start: GridPoint;
|
||||
end: GridPoint;
|
||||
};
|
||||
|
||||
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
|
||||
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
function formatSvgNumber(value: number) {
|
||||
const normalizedValue = Object.is(value, -0) ? 0 : value;
|
||||
return Number(normalizedValue.toFixed(4)).toString();
|
||||
}
|
||||
|
||||
function formatSvgPoint(point: GridPoint) {
|
||||
return `${formatSvgNumber(point.x)} ${formatSvgNumber(point.y)}`;
|
||||
}
|
||||
|
||||
function gridPointKey(point: GridPoint) {
|
||||
return `${formatSvgNumber(point.x)}:${formatSvgNumber(point.y)}`;
|
||||
}
|
||||
|
||||
function distanceBetweenGridPoints(first: GridPoint, second: GridPoint) {
|
||||
return Math.hypot(second.x - first.x, second.y - first.y);
|
||||
}
|
||||
|
||||
function moveGridPointToward(
|
||||
from: GridPoint,
|
||||
target: GridPoint,
|
||||
distance: number,
|
||||
) {
|
||||
const fullDistance = distanceBetweenGridPoints(from, target);
|
||||
if (fullDistance <= 0) {
|
||||
return from;
|
||||
}
|
||||
const ratio = Math.min(1, distance / fullDistance);
|
||||
return {
|
||||
x: from.x + (target.x - from.x) * ratio,
|
||||
y: from.y + (target.y - from.y) * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
function isCollinearGridCorner(
|
||||
previous: GridPoint,
|
||||
current: GridPoint,
|
||||
next: GridPoint,
|
||||
) {
|
||||
return (
|
||||
(previous.x === current.x && current.x === next.x) ||
|
||||
(previous.y === current.y && current.y === next.y)
|
||||
);
|
||||
}
|
||||
|
||||
function removeCollinearGridPoints(points: GridPoint[]) {
|
||||
if (points.length <= 3) {
|
||||
return points;
|
||||
}
|
||||
return points.filter((point, index) => {
|
||||
const previous = points[(index - 1 + points.length) % points.length];
|
||||
const next = points[(index + 1) % points.length];
|
||||
return previous && next && !isCollinearGridCorner(previous, point, next);
|
||||
});
|
||||
}
|
||||
|
||||
function buildRoundedGridCyclePath(
|
||||
points: GridPoint[],
|
||||
radius: number,
|
||||
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
|
||||
) {
|
||||
const cyclePoints = removeCollinearGridPoints(points);
|
||||
if (cyclePoints.length < 3) {
|
||||
return '';
|
||||
}
|
||||
const resolveCorner = (index: number) => {
|
||||
const point = cyclePoints[index];
|
||||
const previous = cyclePoints[
|
||||
(index - 1 + cyclePoints.length) % cyclePoints.length
|
||||
];
|
||||
const next = cyclePoints[(index + 1) % cyclePoints.length];
|
||||
if (!point || !previous || !next) {
|
||||
return null;
|
||||
}
|
||||
const safeRadius = Math.min(
|
||||
radius,
|
||||
distanceBetweenGridPoints(point, previous) / 2,
|
||||
distanceBetweenGridPoints(point, next) / 2,
|
||||
);
|
||||
return {
|
||||
point,
|
||||
entry: moveGridPointToward(point, previous, safeRadius),
|
||||
exit: moveGridPointToward(point, next, safeRadius),
|
||||
};
|
||||
};
|
||||
const firstCorner = resolveCorner(0);
|
||||
if (!firstCorner) {
|
||||
return '';
|
||||
}
|
||||
const commands = [`M ${formatSvgPoint(transformPoint(firstCorner.exit))}`];
|
||||
for (let index = 1; index <= cyclePoints.length; index += 1) {
|
||||
const corner = resolveCorner(index % cyclePoints.length);
|
||||
if (!corner) {
|
||||
continue;
|
||||
}
|
||||
commands.push(`L ${formatSvgPoint(transformPoint(corner.entry))}`);
|
||||
commands.push(
|
||||
`Q ${formatSvgPoint(transformPoint(corner.point))} ${formatSvgPoint(
|
||||
transformPoint(corner.exit),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
commands.push('Z');
|
||||
return commands.join(' ');
|
||||
}
|
||||
|
||||
function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
|
||||
const groupCellKeys = new Set(
|
||||
group.pieces.map((piece) =>
|
||||
buildLocalCellKey(piece.localRow, piece.localCol),
|
||||
),
|
||||
);
|
||||
const hasCell = (row: number, col: number) =>
|
||||
groupCellKeys.has(buildLocalCellKey(row, col));
|
||||
const edges: GridEdge[] = [];
|
||||
|
||||
for (const piece of group.pieces) {
|
||||
const { localRow: row, localCol: col } = piece;
|
||||
if (!hasCell(row - 1, col)) {
|
||||
edges.push({ start: { x: col, y: row }, end: { x: col + 1, y: row } });
|
||||
}
|
||||
if (!hasCell(row, col + 1)) {
|
||||
edges.push({
|
||||
start: { x: col + 1, y: row },
|
||||
end: { x: col + 1, y: row + 1 },
|
||||
});
|
||||
}
|
||||
if (!hasCell(row + 1, col)) {
|
||||
edges.push({
|
||||
start: { x: col + 1, y: row + 1 },
|
||||
end: { x: col, y: row + 1 },
|
||||
});
|
||||
}
|
||||
if (!hasCell(row, col - 1)) {
|
||||
edges.push({ start: { x: col, y: row + 1 }, end: { x: col, y: row } });
|
||||
}
|
||||
}
|
||||
|
||||
const edgeIndexesByStart = new Map<string, number[]>();
|
||||
edges.forEach((edge, index) => {
|
||||
const key = gridPointKey(edge.start);
|
||||
const indexes = edgeIndexesByStart.get(key) ?? [];
|
||||
indexes.push(index);
|
||||
edgeIndexesByStart.set(key, indexes);
|
||||
});
|
||||
|
||||
const unusedEdgeIndexes = new Set(edges.map((_, index) => index));
|
||||
const cycles: GridPoint[][] = [];
|
||||
while (unusedEdgeIndexes.size > 0) {
|
||||
const firstEdgeIndex = unusedEdgeIndexes.values().next().value as
|
||||
| number
|
||||
| undefined;
|
||||
if (firstEdgeIndex === undefined) {
|
||||
break;
|
||||
}
|
||||
const firstEdge = edges[firstEdgeIndex];
|
||||
if (!firstEdge) {
|
||||
unusedEdgeIndexes.delete(firstEdgeIndex);
|
||||
continue;
|
||||
}
|
||||
const cycle: GridPoint[] = [firstEdge.start];
|
||||
let currentEdge = firstEdge;
|
||||
unusedEdgeIndexes.delete(firstEdgeIndex);
|
||||
|
||||
for (let guard = 0; guard < edges.length + 1; guard += 1) {
|
||||
const currentEnd = currentEdge.end;
|
||||
const cycleStart = cycle[0];
|
||||
if (!cycleStart || gridPointKey(currentEnd) === gridPointKey(cycleStart)) {
|
||||
break;
|
||||
}
|
||||
cycle.push(currentEnd);
|
||||
const nextEdgeIndex = (
|
||||
edgeIndexesByStart.get(gridPointKey(currentEnd)) ?? []
|
||||
).find((index) => unusedEdgeIndexes.has(index));
|
||||
if (nextEdgeIndex === undefined) {
|
||||
break;
|
||||
}
|
||||
const nextEdge = edges[nextEdgeIndex];
|
||||
if (!nextEdge) {
|
||||
unusedEdgeIndexes.delete(nextEdgeIndex);
|
||||
break;
|
||||
}
|
||||
currentEdge = nextEdge;
|
||||
unusedEdgeIndexes.delete(nextEdgeIndex);
|
||||
}
|
||||
|
||||
if (cycle.length >= 3) {
|
||||
cycles.push(cycle);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
export function buildMergedGroupOutlinePath(
|
||||
group: PuzzleMergedGroupShape,
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function buildMergedGroupClipPath(
|
||||
group: PuzzleMergedGroupShape,
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) =>
|
||||
buildRoundedGridCyclePath(cycle, radius, (point) => ({
|
||||
x: point.x / group.colSpan,
|
||||
y: point.y / group.rowSpan,
|
||||
})),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function sanitizeSvgId(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
|
||||
export function resolveDraggedPieceCellLayer(
|
||||
pieceId: string | null | undefined,
|
||||
draggingPieceId: string | null,
|
||||
isMerged: boolean,
|
||||
) {
|
||||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||||
return undefined;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
export function resolveDraggedPieceLayer(
|
||||
pieceId: string | null | undefined,
|
||||
draggingPieceId: string | null,
|
||||
isMerged: boolean,
|
||||
) {
|
||||
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
|
||||
return undefined;
|
||||
}
|
||||
return 81;
|
||||
}
|
||||
|
||||
export function resolveDraggedMergedGroupLayer(
|
||||
groupId: string,
|
||||
draggingGroupId: string | null,
|
||||
) {
|
||||
return groupId === draggingGroupId ? 90 : undefined;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -97,11 +97,11 @@ import {
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import {
|
||||
AuthUiContext,
|
||||
type PlatformSettingsSection,
|
||||
} from '../auth/AuthUiContext';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import {
|
||||
RpgEntryFlowShell,
|
||||
type RpgEntryFlowShellProps,
|
||||
@@ -129,17 +129,6 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function expectRpgCreationLocked(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
) {
|
||||
await openCreationHub(user);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
|
||||
await user.click(rpgButton);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function openExistingRpgDraft(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
actionName: string | RegExp = /继续(?:完善|创作)/u,
|
||||
@@ -552,6 +541,7 @@ const mockAuthUser: AuthUser = {
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function buildMockPuzzleRun(
|
||||
@@ -2170,7 +2160,7 @@ test('logged out public detail gates big fish start before local runtime', async
|
||||
);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2844,7 +2834,7 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2917,7 +2907,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -2960,7 +2950,7 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'BF-NPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -3014,7 +3004,7 @@ test('public code search opens a published Match3D work by M3 code and starts ru
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const searchInput = await screen.findByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'M3-EPUBLIC1');
|
||||
await user.click(screen.getByRole('button', { name: '搜索' }));
|
||||
@@ -4133,6 +4123,31 @@ test('authenticated users with save archives default into the saves tab', async
|
||||
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('最近存档')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle save archive highlights work title and level subtitle', async () => {
|
||||
vi.mocked(listProfileSaveArchives).mockResolvedValue([
|
||||
{
|
||||
worldKey: 'puzzle:puzzle-profile-1',
|
||||
ownerUserId: 'user-2',
|
||||
profileId: 'puzzle-profile-1',
|
||||
worldType: 'PUZZLE',
|
||||
worldName: '雨夜猫塔',
|
||||
subtitle: '第 2 关 · 星桥机关',
|
||||
summaryText: '拼图进行中',
|
||||
coverImageSrc: '/generated-puzzle-assets/puzzle-1/level-2.png',
|
||||
lastPlayedAt: '2026-04-19T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
expect((await screen.findAllByText('雨夜猫塔')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('第 2 关 · 星桥机关').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('ARCHIVE')).toBeNull();
|
||||
expect(screen.queryByText('最近存档')).toBeNull();
|
||||
});
|
||||
|
||||
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen, within } from '@testing-library/react';
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type {
|
||||
AuthUser,
|
||||
PublicUserSummary,
|
||||
} from '../../../packages/shared/src/contracts/auth';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
@@ -13,29 +17,122 @@ import {
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
|
||||
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
|
||||
entries: [
|
||||
const {
|
||||
mockBuildReferralCenter,
|
||||
mockGetRpgProfileReferralInviteCenter,
|
||||
mockGetRpgProfileWalletLedger,
|
||||
mockRedeemRpgProfileReferralInviteCode,
|
||||
} = vi.hoisted(() => {
|
||||
const buildReferralCenter = (
|
||||
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
|
||||
): ProfileReferralInviteCenterResponse => ({
|
||||
inviteCode: 'SY12345678',
|
||||
inviteLinkPath: '/?inviteCode=SY12345678',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-04-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-04-28T09:00:00Z',
|
||||
userId: 'user-2',
|
||||
displayName: '被邀请玩家',
|
||||
avatarUrl: null,
|
||||
boundAt: '2026-05-01T08:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-05-01T08:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
return {
|
||||
mockBuildReferralCenter: buildReferralCenter,
|
||||
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
|
||||
buildReferralCenter(),
|
||||
),
|
||||
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||
center: buildReferralCenter({
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user-2',
|
||||
boundAt: '2026-05-01T08:00:00Z',
|
||||
}),
|
||||
inviteeRewardGranted: true,
|
||||
inviterRewardGranted: true,
|
||||
inviteeBalanceAfter: 30,
|
||||
inviterBalanceAfter: 30,
|
||||
})),
|
||||
mockGetRpgProfileWalletLedger: vi.fn(async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
amountDelta: -1,
|
||||
balanceAfter: 29,
|
||||
sourceType: 'asset_operation_consume',
|
||||
createdAt: '2026-04-28T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ledger-2',
|
||||
amountDelta: 30,
|
||||
balanceAfter: 30,
|
||||
sourceType: 'invite_invitee_reward',
|
||||
createdAt: '2026-04-28T09:00:00Z',
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
mockGetPublicAuthUserByCode,
|
||||
mockGetPublicAuthUserById,
|
||||
mockUpdateAuthProfile,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetPublicAuthUserByCode: vi.fn(
|
||||
async (code: string): Promise<PublicUserSummary> => ({
|
||||
id: `id-${code}`,
|
||||
publicUserCode: code,
|
||||
displayName: '公开作者',
|
||||
avatarUrl: null,
|
||||
}),
|
||||
),
|
||||
mockGetPublicAuthUserById: vi.fn(
|
||||
async (userId: string): Promise<PublicUserSummary> => ({
|
||||
id: userId,
|
||||
publicUserCode: `code-${userId}`,
|
||||
displayName: '公开作者',
|
||||
avatarUrl: null,
|
||||
}),
|
||||
),
|
||||
mockUpdateAuthProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
|
||||
getPublicAuthUserById: mockGetPublicAuthUserById,
|
||||
updateAuthProfile: mockUpdateAuthProfile,
|
||||
}));
|
||||
|
||||
mockUpdateAuthProfile.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter,
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
@@ -48,14 +145,14 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
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',
|
||||
},
|
||||
],
|
||||
@@ -75,7 +172,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免陶泥币回合数',
|
||||
benefitName: '免光点回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
@@ -89,7 +186,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60陶泥币',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
@@ -278,6 +375,7 @@ function renderProfileView(
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
...userOverrides,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
@@ -457,6 +555,21 @@ function renderStatefulLoggedOutHomeView(
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
|
||||
mockBuildReferralCenter(),
|
||||
);
|
||||
mockUpdateAuthProfile.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
@@ -482,9 +595,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();
|
||||
@@ -534,17 +647,116 @@ test('wallet ledger modal shows empty and error states', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
test('profile invite shortcut shows reward subtitle and invited users', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
|
||||
|
||||
await user.click(inviteButton);
|
||||
|
||||
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('邀请一个用户注册,双方都可以获得30光点。'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
|
||||
expect(screen.getByText('成功邀请')).toBeTruthy();
|
||||
expect(screen.getByText('被邀请玩家')).toBeTruthy();
|
||||
expect(screen.queryByText('已奖')).toBeNull();
|
||||
expect(screen.queryByText('今日')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
|
||||
renderProfileView();
|
||||
|
||||
const inviteButton = screen.getByRole('button', { name: /邀请好友/u });
|
||||
const redeemButton = await screen.findByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
});
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
|
||||
expect(
|
||||
inviteButton.compareDocumentPosition(redeemButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
redeemButton.compareDocumentPosition(communityButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
|
||||
mockBuildReferralCenter({
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: true,
|
||||
boundInviterUserId: 'user-2',
|
||||
boundAt: '2026-05-01T08:00:00Z',
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderProfileView();
|
||||
await user.click(screen.getByRole('button', { name: /邀请好友/u }));
|
||||
await screen.findByText('成功邀请');
|
||||
const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(firstShortcutRegion).queryByRole('button', { name: /填邀请码/u }),
|
||||
).toBeNull();
|
||||
unmount();
|
||||
|
||||
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
|
||||
const expiredShortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(expiredShortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /填邀请码/u }));
|
||||
const input = await screen.findByLabelText('邀请码');
|
||||
await user.type(input, 'spring-2026');
|
||||
await user.click(screen.getByRole('button', { name: '提交' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRedeemRpgProfileReferralInviteCode).toHaveBeenCalledWith(
|
||||
'SPRING2026',
|
||||
);
|
||||
});
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText('已填写')).toBeTruthy();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
expect(
|
||||
within(shortcutRegion).queryByRole('button', {
|
||||
name: /填邀请码/u,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('opens reward code modal from profile action on mobile', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -620,13 +832,86 @@ test('mobile home search submits public work code', async () => {
|
||||
);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(
|
||||
'输入 SY / CW / BF / M3 / PZ 编号',
|
||||
'搜索作品号、名称、作者、描述',
|
||||
);
|
||||
await user.type(searchInput, 'PZ-PROFILE1{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
|
||||
});
|
||||
|
||||
test('home search fuzzy matches public work id, name, author and description', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
const entries = [
|
||||
{
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-moon-gate',
|
||||
profileId: 'puzzle-profile-moon-gate',
|
||||
publicWorkCode: 'PZ-MOON01',
|
||||
authorDisplayName: '月井守望',
|
||||
worldName: '月井机关',
|
||||
summaryText: '需要沿着银色水路重新点亮机关。',
|
||||
},
|
||||
{
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-fire-bridge',
|
||||
profileId: 'puzzle-profile-fire-bridge',
|
||||
publicWorkCode: 'PZ-FIRE02',
|
||||
authorDisplayName: '晨风',
|
||||
worldName: '火桥谜图',
|
||||
summaryText: '跨过熔岩断桥寻找遗失碎片。',
|
||||
},
|
||||
] satisfies PlatformPublicGalleryCard[];
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: entries,
|
||||
onOpenGalleryDetail,
|
||||
onSearchPublicCode,
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'MOON01{enter}');
|
||||
expect(await screen.findByText('搜索结果')).toBeTruthy();
|
||||
expect(screen.getByText('月井机关')).toBeTruthy();
|
||||
expect(screen.queryByText('火桥谜图')).toBeNull();
|
||||
expect(onSearchPublicCode).not.toHaveBeenCalled();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '火桥{enter}');
|
||||
expect(await screen.findByText('火桥谜图')).toBeTruthy();
|
||||
expect(screen.queryByText('月井机关')).toBeNull();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '月井守望{enter}');
|
||||
expect(await screen.findByText('月井机关')).toBeTruthy();
|
||||
expect(screen.queryByText('火桥谜图')).toBeNull();
|
||||
|
||||
await user.clear(searchInput);
|
||||
await user.type(searchInput, '熔岩断桥{enter}');
|
||||
expect(await screen.findByText('火桥谜图')).toBeTruthy();
|
||||
expect(screen.queryByText('月井机关')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /火桥谜图/u }));
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(entries[1]);
|
||||
});
|
||||
|
||||
test('home search keeps public code fallback when local works do not match', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchPublicCode = vi.fn();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
onSearchPublicCode,
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, 'CW-REMOTE-ONLY{enter}');
|
||||
|
||||
expect(onSearchPublicCode).toHaveBeenCalledWith('CW-REMOTE-ONLY');
|
||||
expect(screen.queryByText('搜索结果')).toBeNull();
|
||||
});
|
||||
|
||||
test('public gallery cards hide work code until detail is opened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenGalleryDetail = vi.fn();
|
||||
@@ -686,6 +971,35 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
|
||||
).toBe('推荐');
|
||||
});
|
||||
|
||||
test('public work cards load real author avatar from public user summary', async () => {
|
||||
mockGetPublicAuthUserById.mockResolvedValueOnce({
|
||||
id: 'user-2',
|
||||
publicUserCode: 'SY-00000002',
|
||||
displayName: '拼图玩家',
|
||||
avatarUrl: 'data:image/png;base64,AUTHOR',
|
||||
});
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
featuredEntries: [puzzlePublicEntry],
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: /奇幻拼图,拼图,20游玩,5改造,12点赞/u,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
card
|
||||
.querySelector('.platform-public-work-card__author-avatar-image')
|
||||
?.getAttribute('src'),
|
||||
).toBe('data:image/png;base64,AUTHOR');
|
||||
});
|
||||
expect(mockGetPublicAuthUserById).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetPublicAuthUserById).toHaveBeenCalledWith('user-2');
|
||||
expect(mockGetPublicAuthUserByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile home feed only rotates the card closest to screen center', () => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -962,6 +962,13 @@ body {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.platform-public-work-card__author-avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.platform-ranking-panel {
|
||||
@@ -4026,10 +4033,34 @@ button {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-image--locked {
|
||||
filter: blur(18px) saturate(0.7);
|
||||
opacity: 0.58;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-lock {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-lock-icon {
|
||||
width: clamp(3.6rem, 18vw, 7rem);
|
||||
height: clamp(3.6rem, 18vw, 7rem);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
filter: drop-shadow(0 1.2rem 2rem rgba(15, 23, 42, 0.34));
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
height: 2.4rem;
|
||||
width: 2.4rem;
|
||||
@@ -4056,7 +4087,7 @@ button {
|
||||
right: 1rem;
|
||||
bottom: 0.8rem;
|
||||
left: 1rem;
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.38rem;
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('authService', () => {
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,6 +131,7 @@ describe('authService', () => {
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -217,6 +219,7 @@ describe('authService', () => {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -295,6 +298,7 @@ describe('authService', () => {
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -317,6 +321,7 @@ describe('authService', () => {
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function updatePuzzleRunPause(
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用正式拼图道具,服务端负责扣除陶泥币并更新运行态。
|
||||
* 使用正式拼图道具,服务端负责扣除光点并更新运行态。
|
||||
*/
|
||||
export async function usePuzzleRuntimeProp(
|
||||
runId: string,
|
||||
|
||||
@@ -99,7 +99,7 @@ export async function deletePuzzleWork(profileId: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 领取当前用户名下拼图作品的整数陶泥币激励。
|
||||
* 领取当前用户名下拼图作品的整数光点激励。
|
||||
*/
|
||||
export async function claimPuzzleWorkPointIncentive(profileId: string) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
|
||||
Reference in New Issue
Block a user