This commit is contained in:
2026-05-01 01:30:02 +08:00
parent aabad6407f
commit 2e9d0f4640
92 changed files with 4548 additions and 248 deletions

View File

@@ -190,6 +190,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
test('creation hub shows puzzle point incentive and claims without opening card', async () => {
const user = userEvent.setup();
const onClaimPuzzlePointIncentive = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-incentive',
profileId: 'puzzle-profile-incentive',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '陶泥灯塔',
summary: '拼图作品会展示积分激励。',
themeTags: ['灯塔', '陶泥'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-01T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-01T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 3,
pointIncentiveTotalHalfPoints: 5,
pointIncentiveClaimedPoints: 1,
pointIncentiveTotalPoints: 2.5,
pointIncentiveClaimablePoints: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive}
/>,
);
expect(screen.getByLabelText('积分激励总数 2.5 陶泥币')).toBeTruthy();
expect(screen.getByLabelText('待领取积分 1 陶泥币')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取积分' }));
expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-incentive' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub

View File

@@ -47,6 +47,8 @@ type CustomWorldCreationHubProps = {
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
};
function EmptyState({ title }: { title: string }) {
@@ -131,6 +133,8 @@ export function CustomWorldCreationHub({
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -222,6 +226,17 @@ export function CustomWorldCreationHub({
}
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(sourceItem);
};
}
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
@@ -281,6 +296,11 @@ export function CustomWorldCreationHub({
onOpen={() => handleOpenShelfItem(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>

View File

@@ -13,6 +13,7 @@ import {
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
formatCreationPointIncentiveTotal,
} from './creationWorkShelf';
type CustomWorldWorkCardProps = {
@@ -21,6 +22,8 @@ type CustomWorldWorkCardProps = {
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<CreationWorkShelfBadgeTone, string> = {
@@ -189,12 +192,17 @@ export function CustomWorldWorkCard({
onOpen,
onDelete = null,
deleteBusy = false,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const isPublished = item.status === 'published';
const canClaimPointIncentive =
Boolean(onClaimPointIncentive) &&
(item.pointIncentive?.claimablePoints ?? 0) > 0;
const displayTitle = formatPlatformWorkDisplayName(item.title);
const { cardRef, deltas, displayValues, showGrowth } =
usePublishedMetricAnimation(
@@ -346,34 +354,81 @@ export function CustomWorldWorkCard({
</div>
{isPublished ? (
<div className="mt-auto grid grid-cols-3 gap-1.5 pt-3 sm:gap-2 sm:pt-4 xl:pt-3">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
<div className="mt-auto space-y-2 pt-3 sm:pt-4 xl:pt-3">
{item.pointIncentive ? (
<div className="creation-work-card-incentive">
<div
aria-label={`积分激励总数 ${formatCreationPointIncentiveTotal(item.pointIncentive.totalPoints)} 陶泥币`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
<span className="creation-work-card-incentive__value">
{formatCreationPointIncentiveTotal(
item.pointIncentive.totalPoints,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</div>
<div
aria-label={`待领取积分 ${item.pointIncentive.claimablePoints} 陶泥币`}
className="creation-work-card-incentive__metric"
>
<span className="creation-work-card-incentive__label">
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
<span className="creation-work-card-incentive__value">
{formatCreationMetricCount(
item.pointIncentive.claimablePoints,
)}
</span>
) : null}
</div>
<button
type="button"
disabled={!canClaimPointIncentive || pointIncentiveBusy}
onClick={(event) => {
event.stopPropagation();
onClaimPointIncentive?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="pointer-events-auto creation-work-card-incentive__button"
>
{pointIncentiveBusy ? '领取中' : '领取积分'}
</button>
</div>
))}
) : null}
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
{item.metrics.map((metric) => (
<div
key={`${item.id}-${metric.id}`}
aria-label={`${metric.label} ${displayValues[metric.id] ?? metric.value}${metric.unit}`}
className={`creation-work-card-stat creation-work-card-stat--${metric.tone}`}
>
<span className="creation-work-card-stat__label">
{metric.label}
</span>
<span className="creation-work-card-stat__value">
<span className="creation-work-card-stat__number">
{formatCreationMetricCount(
displayValues[metric.id] ?? metric.value,
)}
</span>
<span className="creation-work-card-stat__unit">
{metric.unit}
</span>
</span>
{showGrowth && deltas[metric.id] > 0 ? (
<span className="creation-work-card-stat__growth">
<span aria-hidden="true"></span>
{formatCreationMetricCount(deltas[metric.id])}
</span>
) : null}
</div>
))}
</div>
</div>
) : null}
</div>

View File

@@ -35,6 +35,12 @@ export type CreationWorkShelfMetric = {
tone: CreationWorkShelfMetricTone;
};
export type CreationWorkShelfPointIncentive = {
totalHalfPoints: number;
totalPoints: number;
claimablePoints: number;
};
export type CreationWorkShelfSource =
| {
kind: 'rpg';
@@ -66,6 +72,7 @@ export type CreationWorkShelfItem = {
canShare: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
pointIncentive?: CreationWorkShelfPointIncentive;
source: CreationWorkShelfSource;
};
@@ -238,6 +245,21 @@ function mapPuzzleWorkToShelfItem(
likeCount: item.likeCount,
})
: [],
pointIncentive:
status === 'published'
? {
totalHalfPoints: normalizeMetricCount(
item.pointIncentiveTotalHalfPoints,
),
totalPoints: normalizePointIncentiveTotal(
item.pointIncentiveTotalPoints,
item.pointIncentiveTotalHalfPoints,
),
claimablePoints: normalizeMetricCount(
item.pointIncentiveClaimablePoints,
),
}
: undefined,
source: { kind: 'puzzle', item },
};
}
@@ -286,6 +308,24 @@ export function formatCreationMetricCount(value?: number | null) {
return `${normalized}`;
}
export function formatCreationPointIncentiveTotal(value?: number | null) {
const normalized = Math.max(0, value ?? 0);
return Number.isInteger(normalized)
? normalized.toFixed(0)
: normalized.toFixed(1);
}
function normalizePointIncentiveTotal(
totalPoints?: number | null,
totalHalfPoints?: number | null,
) {
if (Number.isFinite(totalPoints)) {
return Math.max(0, totalPoints ?? 0);
}
return normalizeMetricCount(totalHalfPoints) / 2;
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {

View File

@@ -48,6 +48,7 @@ import type {
CustomWorldLibraryEntry,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileSaveArchiveResumeResponse,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
@@ -125,13 +126,18 @@ import {
extendLocalPuzzleTime,
isLocalPuzzleRun,
refreshLocalPuzzleTimer,
resolvePuzzleRestartLevelId,
restartLocalPuzzleLevel,
setLocalPuzzlePaused,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import {
claimPuzzleWorkPointIncentive,
deletePuzzleWork,
listPuzzleWorks,
} from '../../services/puzzle-works';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import {
@@ -141,10 +147,8 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import {
getRpgProfilePlayStats,
resumeRpgProfileSaveArchive,
} from '../../services/rpg-entry/rpgProfileClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
@@ -201,6 +205,16 @@ type PuzzleSaveArchiveState = {
currentLevelId?: unknown;
};
async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) {
return requestRpgRuntimeJson<
ProfileSaveArchiveResumeResponse<PuzzleSaveArchiveState>
>(
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
{ method: 'POST' },
'恢复拼图存档失败',
);
}
type AgentResultBlockerView = {
code?: string;
message: string;
@@ -297,6 +311,10 @@ function mapPublicWorkDetailToPuzzleWork(
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: true,
levels:
entry.coverSlides?.map((slide, index) => ({
@@ -729,11 +747,10 @@ function mergePuzzleServiceRuntimeState(
}
const serviceLevel = serviceRun.currentLevel;
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
if (
currentRun.currentLevel.status === 'cleared' &&
serviceLevel.status !== 'cleared'
) {
return {
...currentRun,
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
@@ -741,8 +758,27 @@ function mergePuzzleServiceRuntimeState(
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
leaderboardEntries:
currentRun.currentLevel.leaderboardEntries.length > 0
? currentRun.currentLevel.leaderboardEntries
: currentRun.leaderboardEntries,
};
}
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
return {
...currentRun,
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
status: serviceLevel.status,
startedAtMs: serviceLevel.startedAtMs,
@@ -836,6 +872,8 @@ export function PlatformEntryFlowShellImpl({
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
string | null
>(null);
const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] =
useState<string | null>(null);
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
const [profilePlayStats, setProfilePlayStats] =
useState<ProfilePlayStatsResponse | null>(null);
@@ -1569,6 +1607,7 @@ export function PlatformEntryFlowShellImpl({
setIsPuzzleNextLevelGenerating(false);
setPuzzleError(null);
setDeletingCreationWorkId(null);
setClaimingPuzzlePointIncentiveProfileId(null);
setProfilePlayStats(null);
setProfilePlayStatsError(null);
setIsProfilePlayStatsOpen(false);
@@ -1812,6 +1851,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(run);
setPuzzleRuntimeReturnStage(returnStage);
setSelectionStage('puzzle-runtime');
void platformBootstrap.refreshSaveArchives();
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-runtime',
@@ -1830,6 +1870,7 @@ export function PlatformEntryFlowShellImpl({
},
[
isPuzzleBusy,
platformBootstrap,
resolvePuzzleErrorMessage,
setIsPuzzleBusy,
setPuzzleError,
@@ -1863,6 +1904,10 @@ export function PlatformEntryFlowShellImpl({
playCount: 0,
remixCount: 0,
likeCount: 0,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
levels: draft.levels,
} satisfies PuzzleWorkSummary;
@@ -1963,9 +2008,7 @@ export function PlatformEntryFlowShellImpl({
}
const timerId = window.setInterval(() => {
if (!isLocalPuzzleRun(puzzleRun)) {
return;
}
// 中文注释:正式 run 的棋盘交互也在前端即时裁决,倒计时展示同样走本地时钟;超时落库仍由 onTimeExpired 拉取后端快照完成。
setPuzzleRun((currentRun) =>
currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun,
);
@@ -2009,7 +2052,7 @@ export function PlatformEntryFlowShellImpl({
const syncPuzzleRuntimeTimeout = useCallback(async () => {
if (
!puzzleRun?.currentLevel ||
puzzleRun.currentLevel.status !== 'playing'
puzzleRun.currentLevel.status === 'cleared'
) {
return;
}
@@ -2040,9 +2083,11 @@ export function PlatformEntryFlowShellImpl({
if (!puzzleRun?.currentLevel) {
return null;
}
const expectedStatus =
propKind === 'extendTime' ? 'failed' : 'playing';
if (puzzleRun.currentLevel.status !== expectedStatus) {
const canUseProp =
propKind === 'extendTime'
? puzzleRun.currentLevel.status !== 'cleared'
: puzzleRun.currentLevel.status === 'playing';
if (!canUseProp) {
return null;
}
@@ -2072,6 +2117,7 @@ export function PlatformEntryFlowShellImpl({
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
void platformBootstrap.refreshProfileDashboard();
void platformBootstrap.refreshSaveArchives();
return nextRun;
},
[platformBootstrap, puzzleRun],
@@ -2084,6 +2130,10 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
const restartLevelId = resolvePuzzleRestartLevelId(
puzzleRun,
selectedPuzzleDetail,
);
if (isLocalPuzzleRun(puzzleRun)) {
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
puzzleRunRef.current = nextRun;
@@ -2098,7 +2148,7 @@ export function PlatformEntryFlowShellImpl({
? selectedPuzzleDetail
: undefined,
false,
currentLevel.levelId ?? null,
restartLevelId,
);
}, [
isPuzzleBusy,
@@ -2120,7 +2170,7 @@ export function PlatformEntryFlowShellImpl({
platformBootstrap.setSaveError(null);
try {
const resumedArchive = await resumeRpgProfileSaveArchive(
const resumedArchive = await resumePuzzleProfileSaveArchiveRaw(
entry.worldKey,
);
platformBootstrap.setSaveEntries((currentEntries) =>
@@ -2130,8 +2180,7 @@ export function PlatformEntryFlowShellImpl({
: currentEntry,
),
);
const gameState = resumedArchive.snapshot
.gameState as PuzzleSaveArchiveState;
const gameState = resumedArchive.snapshot.gameState;
const profileId =
typeof gameState.currentProfileId === 'string' &&
gameState.currentProfileId.trim()
@@ -2145,7 +2194,13 @@ export function PlatformEntryFlowShellImpl({
gameState.currentLevelId.trim()
? gameState.currentLevelId
: null;
await startPuzzleRunFromProfile(profileId, 'platform', undefined, false, levelId);
await startPuzzleRunFromProfile(
profileId,
'platform',
undefined,
false,
levelId,
);
} catch (error) {
platformBootstrap.setSaveError(
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
@@ -2191,7 +2246,27 @@ export function PlatformEntryFlowShellImpl({
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname));
setIsPuzzleLeaderboardBusy(false);
void advanceLocalPuzzleNextLevel({
run: puzzleRun,
sourceSessionId:
selectedPuzzleDetail?.sourceSessionId ??
puzzleSession?.sessionId ??
null,
})
.then(({ run }) => {
setPuzzleRun((currentRun) => {
if (!currentRun) {
return currentRun;
}
return mergePuzzleServiceRuntimeState(currentRun, run);
});
})
.catch(() => {
// 中文注释:本地试玩缺少后端候选时保留本地排行榜和既有下一关入口,避免结算被探测请求打断。
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
});
return;
}
@@ -2203,6 +2278,7 @@ export function PlatformEntryFlowShellImpl({
}
return mergePuzzleServiceRuntimeState(currentRun, run);
});
void platformBootstrap.refreshSaveArchives();
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
@@ -2215,8 +2291,11 @@ export function PlatformEntryFlowShellImpl({
});
}, [
authUi?.user?.displayName,
platformBootstrap,
puzzleRun,
puzzleSession,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setPuzzleError,
]);
@@ -2263,6 +2342,9 @@ export function PlatformEntryFlowShellImpl({
})
: await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
if (!isLocalPuzzleRun(puzzleRun)) {
void platformBootstrap.refreshSaveArchives();
}
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
} finally {
@@ -2272,6 +2354,7 @@ export function PlatformEntryFlowShellImpl({
}, [
isPuzzleBusy,
isPuzzleLeaderboardBusy,
platformBootstrap,
puzzleRun,
puzzleSession,
resolvePuzzleErrorMessage,
@@ -2565,6 +2648,55 @@ export function PlatformEntryFlowShellImpl({
[],
);
const handleClaimPuzzlePointIncentive = useCallback(
(work: PuzzleWorkSummary) => {
if (claimingPuzzlePointIncentiveProfileId) {
return;
}
runProtectedAction(() => {
setClaimingPuzzlePointIncentiveProfileId(work.profileId);
setPuzzleError(null);
void claimPuzzleWorkPointIncentive(work.profileId)
.then((response) => {
const updatedWork = response.item;
setPuzzleWorks((current) =>
current.map((item) =>
mergePuzzleWorkSummary(item, updatedWork),
),
);
setPuzzleGalleryEntries((current) =>
current.map((item) =>
mergePuzzleWorkSummary(item, updatedWork),
),
);
setSelectedPuzzleDetail((current) =>
current ? mergePuzzleWorkSummary(current, updatedWork) : current,
);
syncUpdatedPublicWorkDetail(
mapPuzzleWorkToPublicWorkDetail(updatedWork),
);
})
.catch((error) => {
setPuzzleError(
resolvePuzzleErrorMessage(error, '领取拼图积分激励失败。'),
);
})
.finally(() => {
setClaimingPuzzlePointIncentiveProfileId(null);
});
});
},
[
claimingPuzzlePointIncentiveProfileId,
resolvePuzzleErrorMessage,
runProtectedAction,
setPuzzleError,
syncUpdatedPublicWorkDetail,
],
);
const likePublicWork = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isPublicWorkDetailBusy) {
@@ -3480,6 +3612,10 @@ export function PlatformEntryFlowShellImpl({
onDeletePuzzle={(item) => {
handleDeletePuzzleWork(item);
}}
onClaimPuzzlePointIncentive={(item) => {
handleClaimPuzzlePointIncentive(item);
}}
claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId}
/>
</Suspense>
);

View File

@@ -156,6 +156,7 @@ describe('PuzzleResultView', () => {
expect(screen.getByRole('button', { name: '拼图关卡' })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品信息' })).toBeTruthy();
expect(screen.getByText('雨夜猫街')).toBeTruthy();
expect(screen.getByText('获得更多积分激励')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
expect(screen.getByLabelText('作品名称')).toHaveProperty(

View File

@@ -1025,6 +1025,9 @@ function PuzzleLevelListTab({
<Plus className="h-4 w-4" />
</span>
<span className="mt-1 block text-[11px] font-semibold leading-none text-[var(--platform-text-soft)]">
</span>
</button>
</div>
);

View File

@@ -88,6 +88,7 @@ const clearedRun: PuzzleRunSnapshot = {
currentLevel: {
runId: 'run-1',
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize: 3,
profileId: 'profile-1',
levelName: '潮雾拼图',
@@ -307,6 +308,48 @@ test('当前作品没有下一关时展示三个相似作品并可选择进入',
vi.useRealTimers();
});
test('当前作品没有下一关时底部入口打开相似作品选择', () => {
vi.useFakeTimers();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '通关完成' })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
vi.useRealTimers();
});
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
const authValue = createAuthValue();
@@ -679,6 +722,95 @@ test('倒计时归零时通知父层同步失败态', () => {
vi.useRealTimers();
});
test('失败弹窗支持重开当前关和续时确认', async () => {
const onRestartLevel = vi.fn();
const onUseProp = vi.fn().mockResolvedValue({
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
remainingMs: 60_000,
},
});
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onRestartLevel={onRestartLevel}
onUseProp={onUseProp}
/>,
);
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
expect(onRestartLevel).toHaveBeenCalledTimes(1);
fireEvent.click(
within(failedDialog).getByRole('button', { name: '继续1分钟' }),
);
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(onUseProp).toHaveBeenCalledWith('extendTime');
});
test('失败续时扣费失败时保留确认弹窗', async () => {
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
const failedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'failed',
elapsedMs: 180_000,
remainingMs: 0,
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={failedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
onUseProp={onUseProp}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '继续1分钟' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: '确定' }));
});
expect(screen.getByRole('dialog', { name: '继续1分钟' })).toBeTruthy();
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
});
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);

View File

@@ -756,7 +756,7 @@ export function PuzzleRuntimeShell({
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status !== 'playing') {
if (!run || !currentLevel || currentLevel.status === 'cleared') {
return;
}
if (displayRemainingMs > 0) {

View File

@@ -506,6 +506,7 @@ function buildMockPuzzleRun(
currentLevel: {
runId: `run-${profileId}`,
levelIndex: 1,
levelId: 'puzzle-level-1',
gridSize,
profileId,
levelName,

View File

@@ -505,6 +505,18 @@ test('profile total play time card always uses hours', () => {
expect(within(playTimeCard).queryByText('90分')).toBeNull();
});
test('profile played works card shows count unit', () => {
renderProfileView(vi.fn(), {
playedWorldCount: 1,
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';

View File

@@ -3235,7 +3235,7 @@ export function RpgEntryHomeView({
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
value={formatDashboardCount(playedWorkCount)}
value={`${formatDashboardCount(playedWorkCount)}`}
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
/>

View File

@@ -136,6 +136,25 @@ export function useRpgEntryBootstrap(
return nextEntries;
}, [canReadProtectedData, user]);
const refreshSaveArchives = useCallback(async () => {
if (!user || !canReadProtectedData) {
setSaveEntries([]);
setSaveError(null);
return [];
}
setSaveError(null);
try {
const nextEntries = await listRpgProfileSaveArchives();
setSaveEntries(nextEntries);
return nextEntries;
} catch (error) {
setSaveError(resolveRpgEntryErrorMessage(error, '读取存档列表失败。'));
return [];
}
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
setHistoryError(null);
@@ -371,6 +390,7 @@ export function useRpgEntryBootstrap(
refreshCustomWorldWorks,
refreshPublishedGallery,
refreshSavedCustomWorldLibrary,
refreshSaveArchives,
appendBrowseHistoryEntry,
handleResumeSaveEntry,
};