Add user played work stats for puzzle and big fish
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -36,6 +36,8 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
import {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
recordBigFishPlay,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from '../../services/big-fish-runtime';
|
||||
import {
|
||||
@@ -105,6 +108,7 @@ import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetailByCode,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
@@ -152,6 +156,8 @@ type AgentResultBlockerView = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||
|
||||
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
|
||||
'publish_missing_world_hook',
|
||||
'publish_missing_player_premise',
|
||||
@@ -429,6 +435,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
title: string;
|
||||
publicWorkCode: string;
|
||||
} | null>(null);
|
||||
const [bigFishRuntimeWork, setBigFishRuntimeWork] =
|
||||
useState<BigFishWorkSummary | null>(null);
|
||||
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
|
||||
useState<BigFishRuntimeSessionSource>(null);
|
||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||
useState<MiniGameDraftGenerationState | null>(null);
|
||||
@@ -461,6 +474,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [profilePlayStats, setProfilePlayStats] =
|
||||
useState<ProfilePlayStatsResponse | null>(null);
|
||||
const [profilePlayStatsError, setProfilePlayStatsError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isProfilePlayStatsLoading, setIsProfilePlayStatsLoading] =
|
||||
useState(false);
|
||||
const [isProfilePlayStatsOpen, setIsProfilePlayStatsOpen] = useState(false);
|
||||
const hadReadableProtectedDataRef = useRef(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId &&
|
||||
@@ -973,7 +994,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const bigFishError = bigFishFlow.error;
|
||||
const setBigFishError = bigFishFlow.setError;
|
||||
const isBigFishBusy = bigFishFlow.isBusy;
|
||||
const setIsBigFishBusy = bigFishFlow.setIsBusy;
|
||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||
|
||||
@@ -1021,6 +1041,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishWorks([]);
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1032,6 +1055,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setPuzzleError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
setProfilePlayStats(null);
|
||||
setProfilePlayStatsError(null);
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
resetRpgSessionViewState();
|
||||
setRpgGeneratedCustomWorldProfile(null);
|
||||
setRpgCustomWorldError(null);
|
||||
@@ -1100,6 +1126,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveBigFishFlow = useCallback(() => {
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
@@ -1136,22 +1165,56 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = bigFishSession.sessionId;
|
||||
setBigFishError(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('draft');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
}, [bigFishSession, setSelectionStage]);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
}, [bigFishSession, resolveBigFishErrorMessage, setSelectionStage]);
|
||||
|
||||
const restartBigFishRun = useCallback(() => {
|
||||
if (!bigFishSession && !bigFishRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBigFishError(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||
if (bigFishSession) {
|
||||
setBigFishRuntimeShare(null);
|
||||
}
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
|
||||
setBigFishRun(
|
||||
startLocalBigFishRuntimeRun({
|
||||
session: bigFishSession,
|
||||
work: bigFishRuntimeWork,
|
||||
}),
|
||||
);
|
||||
setSelectionStage('big-fish-runtime');
|
||||
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
}, [
|
||||
bigFishRun,
|
||||
bigFishRuntimeWork,
|
||||
bigFishSession,
|
||||
resolveBigFishErrorMessage,
|
||||
setSelectionStage,
|
||||
]);
|
||||
|
||||
const startPuzzleRunFromProfile = useCallback(
|
||||
async (profileId: string) => {
|
||||
@@ -1241,12 +1304,33 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
setBigFishRun((currentRun) =>
|
||||
currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun,
|
||||
currentRun
|
||||
? advanceLocalBigFishRuntimeRun(currentRun, payload)
|
||||
: currentRun,
|
||||
);
|
||||
},
|
||||
[bigFishRun],
|
||||
);
|
||||
|
||||
const reportBigFishObservedPlayTime = useCallback(() => {
|
||||
const sessionId = bigFishRun?.sessionId?.trim();
|
||||
if (!sessionId || !bigFishRuntimeStartedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(1_000, Date.now() - bigFishRuntimeStartedAt);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩时长失败。'),
|
||||
);
|
||||
});
|
||||
}, [
|
||||
bigFishRun?.sessionId,
|
||||
bigFishRuntimeStartedAt,
|
||||
resolveBigFishErrorMessage,
|
||||
]);
|
||||
|
||||
const swapPuzzlePiecesInRun = useCallback(
|
||||
(payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
@@ -1303,7 +1387,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
})
|
||||
.catch((error) => {
|
||||
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsPuzzleLeaderboardBusy(false);
|
||||
@@ -1673,26 +1759,34 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const startBigFishRunFromWork = useCallback(
|
||||
(item: BigFishWorkSummary) => {
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||
return;
|
||||
}
|
||||
const sessionId = item.sourceSessionId?.trim();
|
||||
if (!sessionId) {
|
||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
|
||||
setBigFishError(null);
|
||||
bigFishFlow.setSession(null);
|
||||
setBigFishRuntimeShare({
|
||||
title: item.title,
|
||||
publicWorkCode,
|
||||
});
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||
);
|
||||
},
|
||||
[bigFishFlow, setSelectionStage],
|
||||
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
|
||||
setBigFishError(null);
|
||||
bigFishFlow.setSession(null);
|
||||
setBigFishRuntimeWork(item);
|
||||
setBigFishRuntimeShare({
|
||||
title: item.title,
|
||||
publicWorkCode,
|
||||
});
|
||||
setBigFishRuntimeStartedAt(Date.now());
|
||||
setBigFishRuntimeSessionSource('work');
|
||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||
setSelectionStage('big-fish-runtime');
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||
);
|
||||
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
},
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
@@ -1841,6 +1935,118 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const openProfilePlayedWorks = useCallback(() => {
|
||||
setIsProfilePlayStatsOpen(true);
|
||||
setIsProfilePlayStatsLoading(true);
|
||||
setProfilePlayStatsError(null);
|
||||
|
||||
void getRpgProfilePlayStats()
|
||||
.then(setProfilePlayStats)
|
||||
.catch((error) => {
|
||||
setProfilePlayStats(null);
|
||||
setProfilePlayStatsError(
|
||||
resolveRpgCreationErrorMessage(error, '读取玩过作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsProfilePlayStatsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openPlayedWork = useCallback(
|
||||
(work: ProfilePlayedWorkSummary) => {
|
||||
const worldType = (work.worldType ?? '').toLowerCase();
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
|
||||
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
|
||||
const profileId =
|
||||
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
|
||||
if (profileId) {
|
||||
void openPuzzleDetail(profileId, { tab: 'profile' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
worldType === 'big_fish' ||
|
||||
worldType === 'big-fish' ||
|
||||
work.worldKey.startsWith('big-fish:')
|
||||
) {
|
||||
const sessionId =
|
||||
work.profileId ?? work.worldKey.replace(/^big-fish:/u, '');
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
void refreshBigFishGallery()
|
||||
.then((entries) => {
|
||||
const matchedEntry = entries.find(
|
||||
(entry) => entry.sourceSessionId === sessionId,
|
||||
);
|
||||
if (matchedEntry) {
|
||||
startBigFishRunFromWork(matchedEntry);
|
||||
return;
|
||||
}
|
||||
startBigFishRunFromWork({
|
||||
workId: `big-fish:${sessionId}`,
|
||||
sourceSessionId: sessionId,
|
||||
ownerUserId: work.ownerUserId ?? '',
|
||||
title: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summary: work.worldSubtitle,
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: work.lastPlayedAt,
|
||||
publishReady: true,
|
||||
levelCount: 0,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setBigFishError(
|
||||
resolveBigFishErrorMessage(error, '进入大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const profileId = work.profileId ?? work.worldKey;
|
||||
const ownerUserId = work.ownerUserId;
|
||||
if (!ownerUserId || !profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
void detailNavigation.openGalleryDetail({
|
||||
ownerUserId,
|
||||
profileId,
|
||||
publicWorkCode: null,
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: work.firstPlayedAt,
|
||||
updatedAt: work.lastPlayedAt,
|
||||
authorDisplayName: work.worldSubtitle,
|
||||
worldName: work.worldTitle,
|
||||
subtitle: work.worldSubtitle,
|
||||
summaryText: '',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
});
|
||||
});
|
||||
},
|
||||
[
|
||||
detailNavigation,
|
||||
openPuzzleDetail,
|
||||
refreshBigFishGallery,
|
||||
resolveBigFishErrorMessage,
|
||||
runProtectedAction,
|
||||
startBigFishRunFromWork,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const publicWorkCode = initialPublicWorkCode?.trim();
|
||||
if (
|
||||
@@ -2096,7 +2302,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
isSearchingPublicCode={isSearchingPublicCode}
|
||||
onOpenProfileDashboardCard={() => {
|
||||
profilePlayStats={profilePlayStats}
|
||||
isProfilePlayStatsOpen={isProfilePlayStatsOpen}
|
||||
isProfilePlayStatsLoading={isProfilePlayStatsLoading}
|
||||
profilePlayStatsError={profilePlayStatsError}
|
||||
onCloseProfilePlayStats={() => {
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
}}
|
||||
onOpenPlayedWork={openPlayedWork}
|
||||
onOpenProfileDashboardCard={(cardKey) => {
|
||||
if (cardKey === 'playedWorks') {
|
||||
openProfilePlayedWorks();
|
||||
return;
|
||||
}
|
||||
if (platformBootstrap.dashboardError) {
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
}
|
||||
@@ -2349,11 +2567,15 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isBigFishBusy}
|
||||
error={bigFishError}
|
||||
onBack={() => {
|
||||
reportBigFishObservedPlayTime();
|
||||
setSelectionStage(
|
||||
bigFishSession ? 'big-fish-result' : 'platform',
|
||||
bigFishRuntimeSessionSource === 'draft'
|
||||
? 'big-fish-result'
|
||||
: 'platform',
|
||||
);
|
||||
}}
|
||||
onRestart={() => {
|
||||
reportBigFishObservedPlayTime();
|
||||
void restartBigFishRun();
|
||||
}}
|
||||
onSubmitInput={submitBigFishInput}
|
||||
@@ -2517,17 +2739,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
|
||||
>
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={
|
||||
isPuzzleBusy ||
|
||||
isPuzzleNextLevelGenerating ||
|
||||
isPuzzleLeaderboardBusy
|
||||
}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
<PuzzleRuntimeShell
|
||||
run={puzzleRun}
|
||||
isBusy={
|
||||
isPuzzleBusy ||
|
||||
isPuzzleNextLevelGenerating ||
|
||||
isPuzzleLeaderboardBusy
|
||||
}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
}}
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
@@ -102,6 +104,12 @@ export interface RpgEntryHomeViewProps {
|
||||
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
|
||||
isSearchingPublicCode?: boolean;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
profilePlayStats?: ProfilePlayStatsResponse | null;
|
||||
isProfilePlayStatsOpen?: boolean;
|
||||
isProfilePlayStatsLoading?: boolean;
|
||||
profilePlayStatsError?: string | null;
|
||||
onCloseProfilePlayStats?: () => void;
|
||||
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
onRechargeSuccess?: () => void | Promise<void>;
|
||||
createTabContent?: ReactNode;
|
||||
}
|
||||
@@ -815,6 +823,21 @@ function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatPlayedWorkType(value: string | null | undefined) {
|
||||
const normalizedValue = (value ?? '').toLowerCase();
|
||||
if (normalizedValue === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
|
||||
return '大鱼';
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
|
||||
return work.profileId?.trim() || work.worldKey;
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
if (user?.publicUserCode?.trim()) {
|
||||
return user.publicUserCode.trim();
|
||||
@@ -1264,6 +1287,108 @@ function ProfileReferralModal({
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePlayedWorksModal({
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onOpenWork,
|
||||
}: {
|
||||
stats: ProfilePlayStatsResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
|
||||
}) {
|
||||
const playedWorks = stats?.playedWorks ?? [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[34rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭玩过作品"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<div className="pr-10">
|
||||
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
|
||||
PLAYED
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-black">玩过作品</div>
|
||||
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>{formatCompactPlayTime(stats?.totalPlayTimeMs ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-20 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : playedWorks.length > 0 ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{playedWorks.map((work) => (
|
||||
<button
|
||||
type="button"
|
||||
key={`${work.worldKey}:${work.lastPlayedAt}`}
|
||||
onClick={() => onOpenWork?.(work)}
|
||||
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
|
||||
>
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="line-clamp-1 text-base font-black text-zinc-950">
|
||||
{work.worldTitle}
|
||||
</div>
|
||||
{work.worldSubtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
|
||||
{work.worldSubtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
|
||||
{formatPlayedWorkType(work.worldType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
|
||||
<span className="truncate">
|
||||
作品号 {formatPlayedWorkId(work)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
最近 {formatSnapshotTime(work.lastPlayedAt)}
|
||||
</span>
|
||||
<span className="truncate">
|
||||
时长 {formatCompactPlayTime(work.lastObservedPlayTimeMs)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
暂无玩过作品
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RpgEntryHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@@ -1288,6 +1413,12 @@ export function RpgEntryHomeView({
|
||||
onSearchPublicCode,
|
||||
isSearchingPublicCode = false,
|
||||
onOpenProfileDashboardCard,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
isProfilePlayStatsLoading = false,
|
||||
profilePlayStatsError = null,
|
||||
onCloseProfilePlayStats,
|
||||
onOpenPlayedWork,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
@@ -2318,6 +2449,15 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2422,6 +2562,15 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
33
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
BigFishSessionResponse,
|
||||
RecordBigFishPlayRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 上报大鱼吃小鱼正式游玩。elapsedMs 为 0 时仅标记玩过作品。
|
||||
*/
|
||||
export function recordBigFishPlay(
|
||||
sessionId: string,
|
||||
payload: RecordBigFishPlayRequest,
|
||||
) {
|
||||
return requestJson<BigFishSessionResponse>(
|
||||
`/api/runtime/big-fish/sessions/${encodeURIComponent(sessionId)}/play`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'记录大鱼吃小鱼游玩失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export {
|
||||
advanceLocalBigFishRuntimeRun,
|
||||
startLocalBigFishRuntimeRun,
|
||||
} from './bigFishLocalRuntime';
|
||||
export { recordBigFishPlay } from './bigFishRuntimeClient';
|
||||
|
||||
Reference in New Issue
Block a user