This commit is contained in:
2026-05-01 20:29:09 +08:00
parent 8718472dbd
commit 87fbf41fab
137 changed files with 2922 additions and 989 deletions

View File

@@ -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]">

View File

@@ -22,6 +22,7 @@ const baseUser: AuthUser = {
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
createdAt: new Date().toISOString(),
};
function renderAccountModal(overrides?: {

View File

@@ -86,6 +86,7 @@ const mockUser: AuthUser = {
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
};
beforeEach(() => {

View File

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

View File

@@ -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: '领取积分' }));

View File

@@ -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">

View File

@@ -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}

View File

@@ -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();
});

View File

@@ -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"

View File

@@ -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 () => {

View File

@@ -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}

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -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: /20512/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

View File

@@ -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;

View File

@@ -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',
},
});

View File

@@ -136,7 +136,7 @@ export async function updatePuzzleRunPause(
}
/**
* 使用正式拼图道具,服务端负责扣除陶泥币并更新运行态。
* 使用正式拼图道具,服务端负责扣除光点并更新运行态。
*/
export async function usePuzzleRuntimeProp(
runId: string,

View File

@@ -99,7 +99,7 @@ export async function deletePuzzleWork(profileId: string) {
}
/**
* 领取当前用户名下拼图作品的整数陶泥币激励。
* 领取当前用户名下拼图作品的整数光点激励。
*/
export async function claimPuzzleWorkPointIncentive(profileId: string) {
return requestJson<PuzzleWorkMutationResponse>(