@@ -200,6 +200,7 @@ function mapBigFishWorkToShelfItem(
|
||||
id: 'level-motion-ready-count',
|
||||
label: `动作 ${item.levelMotionReadyCount}`,
|
||||
},
|
||||
{ id: 'play-count', label: `游玩 ${item.playCount ?? 0}` },
|
||||
...(item.backgroundReady
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
@@ -91,6 +94,7 @@ import {
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
@@ -107,6 +111,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 {
|
||||
@@ -154,6 +159,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',
|
||||
@@ -442,6 +449,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);
|
||||
@@ -475,6 +489,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
string | null
|
||||
>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
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 &&
|
||||
@@ -1041,6 +1063,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setBigFishWorks([]);
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeShare(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
setBigFishError(null);
|
||||
setPuzzleOperation(null);
|
||||
@@ -1052,6 +1077,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setPuzzleError(null);
|
||||
setDeletingCreationWorkId(null);
|
||||
setProfilePlayStats(null);
|
||||
setProfilePlayStatsError(null);
|
||||
setIsProfilePlayStatsOpen(false);
|
||||
resetRpgSessionViewState();
|
||||
setRpgGeneratedCustomWorldProfile(null);
|
||||
setRpgCustomWorldError(null);
|
||||
@@ -1120,6 +1148,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leaveBigFishFlow = useCallback(() => {
|
||||
setBigFishRun(null);
|
||||
setBigFishRuntimeWork(null);
|
||||
setBigFishRuntimeStartedAt(null);
|
||||
setBigFishRuntimeSessionSource(null);
|
||||
setBigFishGenerationState(null);
|
||||
bigFishFlow.leaveFlow();
|
||||
}, [bigFishFlow]);
|
||||
@@ -1156,22 +1187,62 @@ 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, '记录大鱼吃小鱼游玩失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
}, [
|
||||
bigFishSession,
|
||||
refreshBigFishShelf,
|
||||
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) => {
|
||||
@@ -1184,8 +1255,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
pushAppHistoryPath(
|
||||
@@ -1269,6 +1341,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
[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) {
|
||||
@@ -1712,17 +1803,25 @@ export function PlatformEntryFlowShellImpl({
|
||||
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, setSelectionStage],
|
||||
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const handlePublicCodeSearch = useCallback(
|
||||
@@ -1871,6 +1970,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 (
|
||||
@@ -2149,7 +2360,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();
|
||||
}
|
||||
@@ -2402,11 +2625,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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -11,7 +11,29 @@ import {
|
||||
} from './RpgEntryHomeView';
|
||||
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
|
||||
|
||||
const { mockGetRpgProfileWalletLedger } = vi.hoisted(() => ({
|
||||
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',
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
@@ -269,20 +291,34 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('opens recharge modal and submits points product', async () => {
|
||||
test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
await user.click(screen.getByText('会员充值'));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币充值')).toBeTruthy();
|
||||
expect(await screen.findByText('60叙世币')).toBeTruthy();
|
||||
expect(await screen.findByText('叙世币账单')).toBeTruthy();
|
||||
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('资产操作消耗')).toBeTruthy();
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('首充送60叙世币'));
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
||||
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
|
||||
expect(await screen.findByText('加载失败')).toBeTruthy();
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('shows a reachable login entry in logged out mobile shell', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
@@ -34,19 +33,21 @@ import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RedeemProfileRewardCodeResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
redeemRpgProfileRewardCode,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -102,6 +103,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 +822,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();
|
||||
@@ -910,206 +932,191 @@ function ProfileShortcutButton({
|
||||
);
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return Number.isInteger(yuan) ? `¥${yuan}` : `¥${yuan.toFixed(2)}`;
|
||||
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
|
||||
points_recharge: '叙世币充值',
|
||||
invite_inviter_reward: '邀请奖励',
|
||||
invite_invitee_reward: '填写邀请码奖励',
|
||||
snapshot_sync: '账户同步',
|
||||
asset_operation_consume: '资产操作消耗',
|
||||
asset_operation_refund: '资产操作退回',
|
||||
redeem_code_reward: '兑换码奖励',
|
||||
};
|
||||
|
||||
function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
function formatMembershipDuration(days: number) {
|
||||
if (days >= 365) {
|
||||
return '365天';
|
||||
}
|
||||
|
||||
return `${days}天`;
|
||||
}
|
||||
|
||||
function AccountRechargeModal({
|
||||
center,
|
||||
activeTab,
|
||||
function WalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
error,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onSelectProduct,
|
||||
onRetry,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
activeTab: 'points' | 'membership';
|
||||
ledger: ProfileWalletLedgerResponse | null;
|
||||
fallbackBalance: number;
|
||||
isLoading: boolean;
|
||||
isSubmitting: string | null;
|
||||
error: string | null;
|
||||
onTabChange: (tab: 'points' | 'membership') => void;
|
||||
onClose: () => void;
|
||||
onSelectProduct: (product: ProfileRechargeProduct) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const visibleProducts =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
const entries = ledger?.entries ?? [];
|
||||
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
|
||||
|
||||
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,46rem)] w-full max-w-[32rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_34%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
|
||||
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] 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/78 text-[#ff4056] shadow-sm"
|
||||
aria-label="关闭账户充值"
|
||||
aria-label="关闭叙世币账单"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div className="max-h-[min(92vh,46rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
|
||||
<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]">
|
||||
WALLET
|
||||
LEDGER
|
||||
</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-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<div className="mt-1 text-2xl font-black">叙世币账单</div>
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
|
||||
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
|
||||
<span>
|
||||
{center ? `${center.walletBalance}叙世币` : '叙世币账户'}
|
||||
</span>
|
||||
<span>{balance}叙世币</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 rounded-2xl bg-zinc-100/90 p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'points'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
叙世币充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-black transition ${
|
||||
activeTab === 'membership'
|
||||
? 'bg-white text-[#ff4056] shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
会员卡充值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="mt-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-16 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
|
||||
暂无账单记录
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{entries.map((entry) => {
|
||||
const isIncome = entry.amountDelta > 0;
|
||||
const label =
|
||||
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
|
||||
entry.sourceType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-black text-zinc-900">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-semibold text-zinc-500">
|
||||
{formatPlatformWorldTime(entry.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<div
|
||||
className={`text-base font-black ${
|
||||
isIncome ? 'text-emerald-600' : 'text-rose-500'
|
||||
}`}
|
||||
>
|
||||
{formatWalletLedgerAmount(entry.amountDelta)}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
|
||||
余额 {entry.balanceAfter}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RewardCodeRedeemModal({
|
||||
value,
|
||||
isSubmitting,
|
||||
error,
|
||||
success,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-50 flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-sm overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-black">兑换码</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭兑换码"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3 px-5 py-5">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
|
||||
placeholder="输入兑换码"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !value.trim()}
|
||||
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '兑换中' : '兑换'}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{Array.from({ length: activeTab === 'points' ? 6 : 3 }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-xl bg-zinc-100"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{success ? (
|
||||
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : activeTab === 'points' ? (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="relative min-h-[8.45rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white text-center shadow-sm transition hover:border-[#ff4056] disabled:opacity-70"
|
||||
>
|
||||
<div className="h-8 bg-[#ff4056] px-2 py-1.5 text-xs font-black text-white">
|
||||
{product.badgeLabel}
|
||||
</div>
|
||||
<div className="px-2 py-3">
|
||||
<div className="text-xl font-black">
|
||||
{product.pointsAmount}叙世币
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
金额:{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="my-2 h-px bg-zinc-100" />
|
||||
<div className="text-sm text-zinc-800">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
{visibleProducts.map((product) => (
|
||||
<button
|
||||
type="button"
|
||||
key={product.productId}
|
||||
disabled={Boolean(isSubmitting)}
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="group relative min-h-[7.75rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition hover:border-[#ff4056] hover:shadow-md disabled:opacity-70"
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-16 w-16 rounded-bl-[2rem] bg-[#ff4056]/10 transition group-hover:bg-[#ff4056]/16" />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-lg font-black">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-bold text-zinc-500">
|
||||
{formatMembershipDuration(product.durationDays)}
|
||||
</div>
|
||||
</div>
|
||||
<Crown className="h-5 w-5 shrink-0 text-[#ff4056]" />
|
||||
</div>
|
||||
<div className="mt-4 text-2xl font-black text-[#ff4056]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs font-semibold text-zinc-500">
|
||||
{isSubmitting === product.productId
|
||||
? '处理中'
|
||||
: product.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-5 overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
|
||||
<div className="border-b border-zinc-200 px-4 py-3 text-sm font-black">
|
||||
用户等级特权
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid min-w-[30rem] grid-cols-5 text-center text-sm">
|
||||
{center?.benefits.map((benefit) => (
|
||||
<div key={benefit.benefitName} className="contents">
|
||||
<div className="border-b border-zinc-100 bg-zinc-50 px-2 py-3 text-left text-zinc-600">
|
||||
{benefit.benefitName}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 text-zinc-500">
|
||||
{benefit.normalValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-emerald-700">
|
||||
{benefit.monthValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-rose-500">
|
||||
{benefit.seasonValue}
|
||||
</div>
|
||||
<div className="border-b border-zinc-100 px-2 py-3 font-bold text-amber-600">
|
||||
{benefit.yearValue}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1264,6 +1271,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,22 +1397,32 @@ export function RpgEntryHomeView({
|
||||
onSearchPublicCode,
|
||||
isSearchingPublicCode = false,
|
||||
onOpenProfileDashboardCard,
|
||||
profilePlayStats = null,
|
||||
isProfilePlayStatsOpen = false,
|
||||
isProfilePlayStatsLoading = false,
|
||||
profilePlayStatsError = null,
|
||||
onCloseProfilePlayStats,
|
||||
onOpenPlayedWork,
|
||||
onRechargeSuccess,
|
||||
createTabContent,
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeTab, setRechargeTab] = useState<'points' | 'membership'>(
|
||||
'points',
|
||||
const [isRewardCodeOpen, setIsRewardCodeOpen] = useState(false);
|
||||
const [rewardCodeInput, setRewardCodeInput] = useState('');
|
||||
const [isSubmittingRewardCode, setIsSubmittingRewardCode] = useState(false);
|
||||
const [rewardCodeError, setRewardCodeError] = useState<string | null>(null);
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [isLoadingRecharge, setIsLoadingRecharge] = useState(false);
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
||||
const [walletLedger, setWalletLedger] =
|
||||
useState<ProfileWalletLedgerResponse | null>(null);
|
||||
const [walletLedgerError, setWalletLedgerError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
|
||||
const [profilePopupPanel, setProfilePopupPanel] =
|
||||
useState<ProfilePopupPanel | null>(null);
|
||||
const [referralCenter, setReferralCenter] =
|
||||
@@ -1401,35 +1520,22 @@ export function RpgEntryHomeView({
|
||||
}
|
||||
authUi?.openLoginModal();
|
||||
};
|
||||
const openRechargePanel = () => {
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeError(null);
|
||||
setIsLoadingRecharge(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
const loadWalletLedger = () => {
|
||||
setWalletLedgerError(null);
|
||||
setIsLoadingWalletLedger(true);
|
||||
void getRpgProfileWalletLedger()
|
||||
.then(setWalletLedger)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerError(
|
||||
error instanceof Error ? error.message : '读取叙世币账单失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRecharge(false));
|
||||
.finally(() => setIsLoadingWalletLedger(false));
|
||||
};
|
||||
const submitRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
void createRpgProfileRechargeOrder(product.productId)
|
||||
.then((response) => {
|
||||
setRechargeCenter(response.center);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
const openWalletLedgerPanel = () => {
|
||||
setIsWalletLedgerOpen(true);
|
||||
loadWalletLedger();
|
||||
};
|
||||
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
|
||||
setProfilePopupPanel(panel);
|
||||
@@ -1486,6 +1592,30 @@ export function RpgEntryHomeView({
|
||||
})
|
||||
.finally(() => setIsSubmittingReferral(false));
|
||||
};
|
||||
const openRewardCodeModal = () => {
|
||||
setIsRewardCodeOpen(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
};
|
||||
const submitRewardCode = () => {
|
||||
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmittingRewardCode(true);
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
void redeemRpgProfileRewardCode(rewardCodeInput)
|
||||
.then((response: RedeemProfileRewardCodeResponse) => {
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeSuccess(`已到账 ${response.amountGranted} 叙世币`);
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
})
|
||||
.finally(() => setIsSubmittingRewardCode(false));
|
||||
};
|
||||
const submitDesktopSearch = () => {
|
||||
const keyword = desktopSearchKeyword.trim();
|
||||
if (!keyword || !onSearchPublicCode || isSearchingPublicCode) {
|
||||
@@ -1833,17 +1963,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRechargePanel}
|
||||
onClick={openRewardCodeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<Ticket className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] opacity-80">
|
||||
{rechargeCenter?.membership.status === 'active'
|
||||
? '叙世会员'
|
||||
: '普通用户'}
|
||||
</div>
|
||||
<div className="text-xs font-bold">兑换码</div>
|
||||
<div className="text-[10px] opacity-80">叙世币</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -1865,7 +1991,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -1889,7 +2015,7 @@ export function RpgEntryHomeView({
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
onClick={openWalletLedgerPanel}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
@@ -2291,18 +2417,6 @@ export function RpgEntryHomeView({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
<ProfileReferralModal
|
||||
panel={profilePopupPanel}
|
||||
@@ -2318,6 +2432,25 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2395,16 +2528,15 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isRechargeOpen ? (
|
||||
<AccountRechargeModal
|
||||
center={rechargeCenter}
|
||||
activeTab={rechargeTab}
|
||||
isLoading={isLoadingRecharge}
|
||||
isSubmitting={submittingRechargeProductId}
|
||||
error={rechargeError}
|
||||
onTabChange={setRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onSelectProduct={submitRechargeProduct}
|
||||
{isRewardCodeOpen ? (
|
||||
<RewardCodeRedeemModal
|
||||
value={rewardCodeInput}
|
||||
isSubmitting={isSubmittingRewardCode}
|
||||
error={rewardCodeError}
|
||||
success={rewardCodeSuccess}
|
||||
onChange={setRewardCodeInput}
|
||||
onSubmit={submitRewardCode}
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{profilePopupPanel ? (
|
||||
@@ -2422,6 +2554,25 @@ export function RpgEntryHomeView({
|
||||
onSubmitRedeem={submitReferralInviteCode}
|
||||
/>
|
||||
) : null}
|
||||
{isProfilePlayStatsOpen ? (
|
||||
<ProfilePlayedWorksModal
|
||||
stats={profilePlayStats}
|
||||
isLoading={isProfilePlayStatsLoading}
|
||||
error={profilePlayStatsError}
|
||||
onClose={onCloseProfilePlayStats ?? (() => undefined)}
|
||||
onOpenWork={onOpenPlayedWork}
|
||||
/>
|
||||
) : null}
|
||||
{isWalletLedgerOpen ? (
|
||||
<WalletLedgerModal
|
||||
ledger={walletLedger}
|
||||
fallbackBalance={remainingNarrativeCoins}
|
||||
isLoading={isLoadingWalletLedger}
|
||||
error={walletLedgerError}
|
||||
onClose={() => setIsWalletLedgerOpen(false)}
|
||||
onRetry={loadWalletLedger}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user