Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.tsx
T
kdletters cb01d33944 收口个人中心提示与大鱼摘要标签
将个人中心邀请弹窗奖励说明迁移到共享状态提示组件
将大鱼吃小鱼结果页 hero 摘要标签迁移到共享胶囊标签组件
补充充值商品卡购买胶囊暂不抽共享组件的收口文档与团队决策
2026-06-10 16:24:53 +08:00

7693 lines
256 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AlertCircle,
ArrowRight,
BookOpen,
Camera,
CheckCircle2,
ChevronDown,
ChevronRight,
Clock3,
Coins,
Compass,
Copy,
Crown,
Gamepad2,
GitFork,
Heart,
Loader2,
LogIn,
MessageCircle,
Palette,
Pencil,
ScanLine,
Search,
Settings,
Share2,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserRound,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import {
type ComponentType,
type CSSProperties,
type PointerEvent,
type ReactNode,
type RefObject,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
ConfirmWechatProfileRechargeOrderResponse,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
ProfilePlayStatsResponse,
ProfileRechargeCenterResponse,
ProfileRechargeProduct,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { refreshStoredAccessToken } from '../../services/apiClient';
import {
isGeneratedLegacyPath,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
updateAuthProfile,
} from '../../services/authService';
import {
resolveProfileRechargeProductPaymentChannel,
shouldShowRechargeEntry,
WECHAT_H5_PAYMENT_CHANNEL,
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL,
WECHAT_NATIVE_PAYMENT_CHANNEL,
} from '../../services/payment/paymentPlatform';
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
import {
claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder,
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
getRpgProfileTasks,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
watchWechatRpgProfileRechargeOrder,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../common/RuntimeResourcePendingMarker';
import { SquareImageCropModal } from '../common/SquareImageCropModal';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
type SquareImageCropRect,
} from '../common/squareImageCropModel';
import {
type CopyFeedbackState,
useCopyFeedback,
} from '../common/useCopyFeedback';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
canExposePublicWork,
EDUTAINMENT_WORK_TAG,
filterEdutainmentPublicWorks,
filterGeneralPublicWorks,
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildProfileDashboardPresentation,
formatCompactPlayTime,
formatPlayedWorkId,
formatPlayedWorkType,
formatTotalPlayTimeHours,
} from './rpgEntryProfileDashboardPresentation';
import {
buildMembershipLabel,
buildRechargeProductValueLabel,
buildWalletLedgerPresentation,
formatRechargePrice,
} from './rpgEntryProfileFundsViewModel';
import {
buildProfileTaskCardSummary,
buildProfileTaskProgressLabel,
getProfileTaskClaimButtonLabel,
getProfileTaskStatusLabel,
selectProfileTaskCenterTasks,
} from './rpgEntryProfileTaskViewModel';
import {
buildPlatformRankingEntries,
buildPlatformRecommendFeedEntries,
buildPublicCategoryGroups,
buildPublicGalleryCardKey,
dedupePlatformPublicGalleryEntries,
DEFAULT_PLATFORM_CATEGORY_KIND_FILTER,
DEFAULT_PLATFORM_CATEGORY_SORT_MODE,
DEFAULT_PLATFORM_RANKING_TAB,
filterPlatformWorkSearchResults,
filterTodayPublishedEntries,
getAllPlatformPublicEntries,
getNextPlatformCategorySortMode,
getPlatformCategoryKindFilterOption,
getPlatformCategoryPrimaryMetric,
getPlatformCategorySortOption,
getPlatformPublicEntries,
getPlatformRankingMetric,
getPlatformRankingTabConfig,
getPlatformSearchableWorkIds,
getPlatformWorldLikeCount,
getPlatformWorldPlayCount,
getPlatformWorldRemixCount,
isExactPublicWorkCodeSearch,
matchesPlatformCategoryKindFilter,
PLATFORM_CATEGORY_KIND_FILTERS,
PLATFORM_CATEGORY_SORT_OPTIONS,
PLATFORM_RANKING_TABS,
type PlatformCategoryKindFilter,
type PlatformCategorySortMode,
type PlatformRankingMetric,
type PlatformRankingTab,
selectPlatformRecommendFeedWindow,
sortPlatformCategoryEntries,
} from './rpgEntryPublicGalleryViewModel';
import {
buildRecommendSwipeRailClassName,
clampRecommendDragOffset,
hasRecommendDragStarted,
RECOMMEND_ENTRY_COMMIT_ANIMATION_MS,
type RecommendSwipeDirection,
resolveRecommendCommitOffset,
resolveRecommendDragCommitDirection,
shouldAnimateRecommendSwipe,
} from './rpgEntryRecommendSwipeDeckModel';
import {
buildPlatformWorldDisplayTags,
describePlatformPublicWorkKind,
describePlatformThemeLabel,
formatPlatformCompactCount,
formatPlatformPublicAuthorAvatarLabel,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isPuzzleGalleryEntry,
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformPublicWorkAuthorLookup,
resolvePlatformPublicWorkAuthorLookup,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldFallbackCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
export type PlatformHomeTab =
| 'home'
| 'category'
| 'create'
| 'saves'
| 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
isDesktopLayout?: boolean;
onTabChange: (tab: PlatformHomeTab) => void;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
historyEntries: PlatformBrowseHistoryEntry[];
profileDashboard: ProfileDashboardSummary | null;
isLoadingPlatform: boolean;
isLoadingDashboard: boolean;
isResumingSaveWorldKey: string | null;
platformError: string | null;
dashboardError: string | null;
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: PlatformPublicGalleryCard) => void;
onOpenChildMotionDemo?: () => void;
onOpenBabyLoveDrawing?: () => void;
onOpenRecommendGalleryDetail?: (entry: PlatformPublicGalleryCard) => void;
recommendRuntimeContent?: ReactNode;
activeRecommendEntryKey?: string | null;
isStartingRecommendEntry?: boolean;
isRecommendRuntimeReady?: boolean;
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
onDeleteLibraryEntry?: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
deletingLibraryEntryId?: string | null;
onSearchPublicCode?: (keyword: string) => void | Promise<boolean | void>;
isSearchingPublicCode?: boolean;
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
profilePlayStats?: ProfilePlayStatsResponse | null;
isProfilePlayStatsOpen?: boolean;
isProfilePlayStatsLoading?: boolean;
profilePlayStatsError?: string | null;
onCloseProfilePlayStats?: () => void;
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onOpenFeedback?: () => void;
onRechargeSuccess?: () => void | Promise<void>;
profileTaskRefreshKey?: number;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
hasUnreadDraftUpdate?: boolean;
}
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card min-w-0';
const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_PROFILE_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 pb-2';
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
'create',
'saves',
'profile',
];
const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
const RECOMMEND_RUNTIME_RESOURCE_IDLE_MS = 80;
const RECOMMEND_RUNTIME_READY_FRAME_COUNT = 2;
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
function resolveRecommendDisplayCoverImage(
imageSrc: string,
fallbackSrc: string,
resolvedCoverUrls?: RecommendResolvedCoverUrlMap,
) {
const normalizedImageSrc = imageSrc.trim();
const normalizedFallbackSrc = fallbackSrc.trim();
if (!normalizedImageSrc) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
const resolvedImageSrc = resolvedCoverUrls?.get(normalizedImageSrc);
if (resolvedImageSrc) {
return resolvedImageSrc;
}
if (isGeneratedLegacyPath(normalizedImageSrc)) {
return (
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
);
}
return normalizedImageSrc;
}
function resolveRecommendCardCoverImage(entry: PlatformPublicGalleryCard) {
const cardCoverSlide = resolvePlatformWorldCoverSlides(entry)[0] ?? null;
return (
cardCoverSlide?.imageSrc.trim() || resolvePlatformWorldCoverImage(entry)
);
}
function collectRecommendCoverPreloadUrls(
entries: PlatformPublicGalleryCard[],
) {
const urls = new Set<string>();
entries.forEach((entry) => {
resolvePlatformWorldCoverSlides(entry).forEach((slide) => {
const slideImageSrc = slide.imageSrc.trim();
if (slideImageSrc) {
urls.add(slideImageSrc);
}
});
[
resolveRecommendCardCoverImage(entry),
resolvePlatformWorldCoverImage(entry),
resolvePlatformWorldFallbackCoverImage(entry),
]
.map((url) => url.trim())
.filter(Boolean)
.forEach((url) => urls.add(url));
});
return [...urls];
}
function useResolvedRecommendCoverImages(
entries: PlatformPublicGalleryCard[],
): RecommendResolvedCoverUrlMap {
const preloadUrls = useMemo(
() => collectRecommendCoverPreloadUrls(entries),
[entries],
);
const preloadKey = preloadUrls.join('\n');
const [resolvedCoverUrls, setResolvedCoverUrls] = useState<
Map<string, string>
>(() => new Map());
useEffect(() => {
let cancelled = false;
const cleanupCallbacks: Array<() => void> = [];
const preloadCoverImage = (
imageSrc: string,
onLoaded?: (loadedImageSrc: string) => void,
) => {
if (!imageSrc || typeof Image === 'undefined') {
onLoaded?.(imageSrc);
return;
}
const image = new Image();
const cleanupImage = () => {
image.onload = null;
image.onerror = null;
};
const finishImageLoad = () => {
if (cancelled) {
return;
}
cleanupImage();
onLoaded?.(imageSrc);
};
const finishImageError = () => {
if (cancelled) {
return;
}
cleanupImage();
};
image.decoding = 'async';
image.onload = finishImageLoad;
image.onerror = finishImageError;
image.src = imageSrc;
if (image.complete) {
finishImageLoad();
}
cleanupCallbacks.push(cleanupImage);
};
setResolvedCoverUrls((currentUrls) => {
const nextUrls = new Map<string, string>();
preloadUrls.forEach((url) => {
const cachedUrl = currentUrls.get(url);
if (cachedUrl) {
nextUrls.set(url, cachedUrl);
return;
}
if (!isGeneratedLegacyPath(url)) {
nextUrls.set(url, url);
}
});
return nextUrls;
});
preloadUrls.forEach((url) => {
if (!isGeneratedLegacyPath(url)) {
preloadCoverImage(url);
return;
}
void resolveAssetReadUrl(url)
.then((resolvedUrl) => {
if (cancelled || !resolvedUrl) {
return;
}
preloadCoverImage(resolvedUrl, (loadedUrl) => {
if (cancelled) {
return;
}
setResolvedCoverUrls((currentUrls) => {
if (currentUrls.get(url) === loadedUrl) {
return currentUrls;
}
const nextUrls = new Map(currentUrls);
nextUrls.set(url, loadedUrl);
return nextUrls;
});
});
})
.catch(() => undefined);
});
return () => {
cancelled = true;
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
};
}, [preloadKey, preloadUrls]);
return resolvedCoverUrls;
}
function scheduleRecommendRuntimeReady(
signal: AbortSignal,
onReady: () => void,
) {
if (signal.aborted) {
return null;
}
let animationFrameId: number | null = null;
let remainingFrameCount = RECOMMEND_RUNTIME_READY_FRAME_COUNT;
const tick = () => {
animationFrameId = null;
if (signal.aborted) {
return;
}
remainingFrameCount -= 1;
if (remainingFrameCount <= 0) {
onReady();
return;
}
animationFrameId = window.requestAnimationFrame(tick);
};
animationFrameId = window.requestAnimationFrame(tick);
return () => {
if (animationFrameId !== null) {
window.cancelAnimationFrame(animationFrameId);
}
};
}
function getRecommendRuntimeImageSource(image: HTMLImageElement) {
return (
image.currentSrc ||
image.getAttribute('src') ||
image.getAttribute('srcset') ||
''
).trim();
}
function getRecommendRuntimeMediaSource(media: HTMLMediaElement) {
return (media.currentSrc || media.getAttribute('src') || '').trim();
}
function collectRecommendRuntimeBackgroundUrls(root: HTMLElement) {
const urls = new Set<string>();
root.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
const backgroundImage = element.style.backgroundImage;
if (!backgroundImage || backgroundImage === 'none') {
return;
}
const pattern = /url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)/giu;
let match: RegExpExecArray | null;
while ((match = pattern.exec(backgroundImage))) {
const url = (match[1] ?? match[2] ?? match[3] ?? '').trim();
if (url) {
urls.add(url);
}
}
});
return urls;
}
function readyRecommendRuntime(
root: HTMLElement | null,
signal: AbortSignal,
): Promise<boolean> {
if (!root || signal.aborted) {
return Promise.resolve(false);
}
const runtimeRoot = root;
return new Promise((resolve) => {
let scanFrameId: number | null = null;
let readyIdleTimeoutId: number | null = null;
let pendingRecheckTimeoutId: number | null = null;
let readyFrameCleanup: (() => void) | null = null;
let settled = false;
const cleanupCallbacks: Array<() => void> = [];
const pendingImageListeners = new Map<
HTMLImageElement,
{ src: string; cleanup: () => void }
>();
const pendingMediaListeners = new Map<
HTMLMediaElement,
{ src: string; cleanup: () => void }
>();
const settledImageSources = new WeakMap<HTMLImageElement, string>();
const settledMediaSources = new WeakMap<HTMLMediaElement, string>();
const loadedBackgroundUrls = new Set<string>();
const pendingBackgroundPreloads = new Map<string, () => void>();
const cancelReadySchedule = () => {
if (readyIdleTimeoutId !== null) {
window.clearTimeout(readyIdleTimeoutId);
readyIdleTimeoutId = null;
}
if (readyFrameCleanup) {
readyFrameCleanup();
readyFrameCleanup = null;
}
};
const cancelPendingRecheck = () => {
if (pendingRecheckTimeoutId !== null) {
window.clearTimeout(pendingRecheckTimeoutId);
pendingRecheckTimeoutId = null;
}
};
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
cancelReadySchedule();
cancelPendingRecheck();
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
if (scanFrameId !== null) {
window.cancelAnimationFrame(scanFrameId);
}
resolve(value);
};
const abort = () => finish(false);
signal.addEventListener('abort', abort, { once: true });
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
cleanupCallbacks.push(() => {
pendingImageListeners.forEach(({ cleanup }) => cleanup());
pendingImageListeners.clear();
pendingMediaListeners.forEach(({ cleanup }) => cleanup());
pendingMediaListeners.clear();
pendingBackgroundPreloads.forEach((cleanup) => cleanup());
pendingBackgroundPreloads.clear();
});
const scheduleScan = () => {
if (settled) {
return;
}
cancelReadySchedule();
cancelPendingRecheck();
if (scanFrameId !== null) {
return;
}
scanFrameId = window.requestAnimationFrame(() => {
scanFrameId = null;
scanResources();
});
};
const scheduleReady = () => {
cancelReadySchedule();
readyIdleTimeoutId = window.setTimeout(() => {
readyIdleTimeoutId = null;
readyFrameCleanup = scheduleRecommendRuntimeReady(signal, () =>
finish(true),
);
if (readyFrameCleanup === null) {
finish(false);
}
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
};
const preloadBackgroundUrl = (url: string) => {
if (loadedBackgroundUrls.has(url) || pendingBackgroundPreloads.has(url)) {
return;
}
if (typeof Image === 'undefined') {
loadedBackgroundUrls.add(url);
return;
}
const image = new Image();
const cleanup = () => {
image.onload = null;
image.onerror = null;
};
const markReady = () => {
cleanup();
pendingBackgroundPreloads.delete(url);
loadedBackgroundUrls.add(url);
scheduleScan();
};
image.decoding = 'async';
image.onload = markReady;
image.onerror = markReady;
pendingBackgroundPreloads.set(url, cleanup);
image.src = url;
if (image.complete) {
markReady();
}
};
function scanResources() {
if (signal.aborted) {
finish(false);
return;
}
const currentImages = new Set(
Array.from(runtimeRoot.querySelectorAll<HTMLImageElement>('img')),
);
const currentMedia = new Set(
Array.from(
runtimeRoot.querySelectorAll<HTMLMediaElement>('audio,video'),
),
);
pendingImageListeners.forEach((entry, image) => {
const currentSrc = getRecommendRuntimeImageSource(image);
if (
!currentImages.has(image) ||
currentSrc !== entry.src ||
!currentSrc
) {
entry.cleanup();
pendingImageListeners.delete(image);
}
});
pendingMediaListeners.forEach((entry, media) => {
const currentSrc = getRecommendRuntimeMediaSource(media);
if (
!currentMedia.has(media) ||
currentSrc !== entry.src ||
!currentSrc
) {
entry.cleanup();
pendingMediaListeners.delete(media);
}
});
currentImages.forEach((image) => {
const imageSrc = getRecommendRuntimeImageSource(image);
const settledImageSrc = settledImageSources.get(image);
if (settledImageSrc && settledImageSrc !== imageSrc) {
settledImageSources.delete(image);
}
if (!imageSrc || image.complete) {
if (imageSrc && image.complete) {
settledImageSources.set(image, imageSrc);
}
return;
}
if (settledImageSources.get(image) === imageSrc) {
return;
}
const existingEntry = pendingImageListeners.get(image);
if (existingEntry?.src === imageSrc) {
return;
}
existingEntry?.cleanup();
const markImageReady = () => {
const activeEntry = pendingImageListeners.get(image);
if (activeEntry?.src !== imageSrc) {
return;
}
settledImageSources.set(image, imageSrc);
activeEntry.cleanup();
pendingImageListeners.delete(image);
scheduleScan();
};
const cleanupImageListeners = () => {
image.removeEventListener('load', markImageReady);
image.removeEventListener('error', markImageReady);
};
image.addEventListener('load', markImageReady, { once: true });
image.addEventListener('error', markImageReady, { once: true });
pendingImageListeners.set(image, {
src: imageSrc,
cleanup: cleanupImageListeners,
});
if (image.complete) {
markImageReady();
}
});
currentMedia.forEach((media) => {
const mediaSrc = getRecommendRuntimeMediaSource(media);
const settledMediaSrc = settledMediaSources.get(media);
const mediaReadyThreshold =
typeof HTMLMediaElement !== 'undefined'
? HTMLMediaElement.HAVE_CURRENT_DATA
: 2;
if (settledMediaSrc && settledMediaSrc !== mediaSrc) {
settledMediaSources.delete(media);
}
if (
!mediaSrc ||
media.readyState >= mediaReadyThreshold ||
media.error
) {
if (mediaSrc && media.readyState >= mediaReadyThreshold) {
settledMediaSources.set(media, mediaSrc);
}
return;
}
if (settledMediaSources.get(media) === mediaSrc) {
return;
}
const existingEntry = pendingMediaListeners.get(media);
if (existingEntry?.src === mediaSrc) {
return;
}
existingEntry?.cleanup();
const markMediaReady = () => {
const activeEntry = pendingMediaListeners.get(media);
if (activeEntry?.src !== mediaSrc) {
return;
}
settledMediaSources.set(media, mediaSrc);
activeEntry.cleanup();
pendingMediaListeners.delete(media);
scheduleScan();
};
const cleanupMediaListeners = () => {
media.removeEventListener('loadeddata', markMediaReady);
media.removeEventListener('canplaythrough', markMediaReady);
media.removeEventListener('error', markMediaReady);
};
media.addEventListener('loadeddata', markMediaReady, { once: true });
media.addEventListener('canplaythrough', markMediaReady, {
once: true,
});
media.addEventListener('error', markMediaReady, { once: true });
pendingMediaListeners.set(media, {
src: mediaSrc,
cleanup: cleanupMediaListeners,
});
if (media.readyState >= mediaReadyThreshold || media.error) {
markMediaReady();
}
});
const currentBackgroundUrls =
collectRecommendRuntimeBackgroundUrls(runtimeRoot);
pendingBackgroundPreloads.forEach((cleanup, url) => {
if (!currentBackgroundUrls.has(url)) {
cleanup();
pendingBackgroundPreloads.delete(url);
}
});
currentBackgroundUrls.forEach((url) => preloadBackgroundUrl(url));
const hasPendingResourceMarker = Boolean(
runtimeRoot.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR),
);
if (
hasPendingResourceMarker ||
pendingImageListeners.size > 0 ||
pendingMediaListeners.size > 0 ||
pendingBackgroundPreloads.size > 0
) {
cancelReadySchedule();
if (pendingRecheckTimeoutId === null) {
pendingRecheckTimeoutId = window.setTimeout(() => {
pendingRecheckTimeoutId = null;
scheduleScan();
}, RECOMMEND_RUNTIME_RESOURCE_IDLE_MS);
}
return;
}
cancelPendingRecheck();
scheduleReady();
}
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(scheduleScan);
observer.observe(runtimeRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
'src',
'srcset',
'style',
'data-runtime-resource-pending',
],
});
cleanupCallbacks.push(() => observer.disconnect());
}
scanResources();
});
}
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) {
const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS;
const nextDayStart =
Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS +
PROFILE_TASK_DAY_MS;
const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS;
return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs);
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = {
detect: (source: CanvasImageSource) => Promise<Array<{ rawValue?: string }>>;
};
type BarcodeDetectorConstructorLike = new (options?: {
formats?: string[];
}) => BarcodeDetectorLike;
type RechargeTab = 'points' | 'membership';
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
type WechatPayResult = {
requestId: string;
orderId: string | null;
status: WechatMiniProgramPaymentStatus;
errorMessage: string | null;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
kind: RechargePaymentResultKind;
title: string;
message: string;
};
type WechatRechargeOrderConfirmationState = {
orderId: string;
};
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
function getBarcodeDetectorConstructor(): BarcodeDetectorConstructorLike | null {
const maybeDetector = (
globalThis as unknown as {
BarcodeDetector?: BarcodeDetectorConstructorLike;
}
).BarcodeDetector;
return typeof maybeDetector === 'function' ? maybeDetector : null;
}
type NativeWechatPaymentState = WechatNativePayment & {
orderId: string;
isConfirming: boolean;
};
type DiscoverChannel =
| 'recommend'
| 'today'
| 'category'
| 'ranking'
| 'edutainment';
const COMMUNITY_QR_CODES = [
{
label: '微信群',
src: communityWechatQrImage,
alt: '玩家社区微信群二维码',
},
{
label: 'QQ群',
src: communityQqQrImage,
alt: '玩家社区 QQ 群二维码',
},
] as const;
const DISCOVER_CHANNELS: Array<{
id: DiscoverChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日' },
{ id: 'category', label: '分类' },
{ id: 'ranking', label: '排行' },
];
const EDUTAINMENT_DISCOVER_CHANNEL = {
id: 'edutainment',
label: EDUTAINMENT_WORK_TAG,
} as const;
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
title: '宝贝爱画',
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const CHILD_MOTION_DEMO_DEFAULT_CARD = {
title: '热身关卡',
subtitle: '动作识别热身',
summary: '站位、招手和左右手活动。',
};
function ResolvedAssetBackdrop({
src,
fallbackSrc,
alt,
className,
ariaHidden = false,
}: {
src?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
ariaHidden?: boolean;
}) {
return (
<ResolvedAssetImage
src={src}
fallbackSrc={fallbackSrc}
alt={alt}
aria-hidden={ariaHidden}
className={className}
/>
);
}
function PlatformWorkCoverArtwork({
entry,
imageSrc,
fallbackSrc,
alt,
className,
}: {
entry: PlatformPublicGalleryCard;
imageSrc?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
}) {
if (isBarkBattleGalleryEntry(entry)) {
return (
<CustomWorldCoverArtwork
imageSrc={imageSrc}
fallbackImageSrc={fallbackSrc}
title={entry.worldName}
fallbackLabel="封面"
renderMode={entry.coverRenderMode}
characterImageSrcs={entry.coverCharacterImageSrcs}
className={className}
/>
);
}
return (
<ResolvedAssetBackdrop
src={imageSrc}
fallbackSrc={fallbackSrc}
alt={alt}
className={className}
/>
);
}
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
);
}
function PublicCodeSearchBar({
value,
onChange,
onSubmit,
isSearching,
className,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
isSearching: boolean;
className?: string;
}) {
return (
<div
className={`platform-desktop-search flex min-w-0 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)] ${className ?? ''}`}
>
<Search className="h-4 w-4 shrink-0" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
placeholder="搜索作品号、名称、作者、描述"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
onClick={onSubmit}
disabled={!value.trim() || isSearching}
className="shrink-0 text-xs font-semibold text-[var(--platform-text-soft)] disabled:opacity-50"
>
{isSearching ? '搜索中' : '搜索'}
</button>
</div>
);
}
function SaveArchivePreview({
entry,
className,
}: {
entry: ProfileSaveArchiveSummary;
className: string;
}) {
return (
<div
aria-hidden="true"
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<ResolvedAssetBackdrop
src={entry.coverImageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
</div>
);
}
function WorldCard({
entry,
onClick,
className,
authorAvatarUrl,
authorSummary,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
variant = 'standard',
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
variant?: 'standard' | 'immersive';
}) {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const fallbackAssetCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
return resolvePlatformWorldCoverSlides(entry);
}, [enableCoverCarousel, entry, fallbackCoverImage]);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const visibleCoverIndex = isCoverCarouselActive ? activeCoverIndex : 0;
const activeCoverSlide =
coverSlides[visibleCoverIndex] ?? coverSlides[0] ?? null;
const coverImage = activeCoverSlide?.imageSrc ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 3);
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const typeLabel = describePlatformPublicWorkKind(entry);
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const cardLabel = `${entry.worldName}${typeLabel}${formatPlatformCompactCount(playCount)}游玩,${formatPlatformCompactCount(remixCount)}改造,${formatPlatformCompactCount(likeCount)}点赞`;
const coverStats = [
{
label: '游玩',
value: playCount,
icon: Gamepad2,
},
{
label: '改造',
value: remixCount,
icon: Pencil,
},
{
label: '点赞',
value: likeCount,
icon: Heart,
},
];
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.ownerUserId, entry.profileId, coverSlides.length]);
useEffect(() => {
if (!isCoverCarouselActive) {
setActiveCoverIndex(0);
}
}, [isCoverCarouselActive]);
useEffect(() => {
if (!isCoverCarouselActive || coverSlides.length <= 1) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, isCoverCarouselActive]);
return (
<button
type="button"
onClick={onClick}
aria-label={cardLabel}
data-mobile-feed-card-key={feedCardKey}
className={`platform-public-work-card ${variant === 'immersive' ? 'platform-public-work-card--immersive' : ''} platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
<div className="platform-public-work-card__cover-stats">
{coverStats.map(({ label, value, icon: Icon }) => (
<span key={label} className="platform-public-work-card__cover-stat">
<Icon
className={`h-3.5 w-3.5 ${label === '点赞' ? 'fill-current' : ''}`}
aria-hidden="true"
/>
<span>{formatPlatformCompactCount(value)}</span>
</span>
))}
</div>
</div>
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="platform-public-work-card__author-avatar-image"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="line-clamp-1 break-words text-[11px] font-semibold text-[var(--platform-text-soft)]">
{authorName}
</span>
</div>
</div>
<span className="platform-public-work-card__kind shrink-0">
{typeLabel}
</span>
</div>
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
{tags.length > 0 ? (
tags.map((tag, index) => (
<PlatformPillBadge
key={`world-tag-${index}-${tag || 'empty'}`}
tone="neutral"
size="xxs"
className="max-w-full px-2.5 tracking-[0.18em]"
>
<span className="truncate">{tag}</span>
</PlatformPillBadge>
))
) : (
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
)}
</div>
</div>
</button>
);
}
function CreationLibraryCard({
entry,
onClick,
onDelete,
isDeleting = false,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
onClick: () => void;
onDelete?: () => void;
isDeleting?: boolean;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
const metaLabel =
entry.visibility === 'published'
? formatPlatformWorldTime(entry.publishedAt)
: '仅自己可见';
const primaryTag =
buildPlatformWorldDisplayTags(entry, 1)[0] ??
formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const summaryText =
entry.summaryText || entry.subtitle || '继续补完这个世界的设定与游玩入口。';
return (
<button
type="button"
onClick={onClick}
className="platform-surface platform-interactive-card relative flex min-h-[13rem] w-full min-w-0 flex-col overflow-hidden px-3 py-3 text-left sm:min-h-[14rem] sm:px-3.5 sm:py-3.5"
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
/>
) : null}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
{onDelete ? (
<PlatformActionButton
tone="danger"
size="xs"
shape="pill"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={isDeleting}
className="absolute right-2 top-2 z-20 min-h-0 px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? '删除中' : '删除'}
</PlatformActionButton>
) : null}
<div className="relative z-10 flex h-full min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<PlatformPillBadge
tone={
entry.visibility === 'published' ? 'darkEmerald' : 'darkAmber'
}
size="xxs"
className="px-2 font-semibold tracking-[0.12em]"
>
{statusLabel}
</PlatformPillBadge>
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="min-w-0 max-w-full px-2 font-medium text-[var(--platform-text-base)]"
>
<span className="truncate">{metaLabel}</span>
</PlatformPillBadge>
</div>
<div className="mt-auto min-w-0">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
{summaryText}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="min-w-0 max-w-full px-2 font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]"
>
<span className="truncate">{primaryTag}</span>
</PlatformPillBadge>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
<span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</span>
</div>
</div>
</div>
</button>
);
}
function RecommendRuntimePreviewCard({
entry,
position,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
position?: 'previous' | 'next' | 'cover';
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const rawCoverImage = resolveRecommendCardCoverImage(entry);
const rawFallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
const resolvedCoverImage = resolveRecommendDisplayCoverImage(
rawCoverImage,
rawFallbackCoverImage,
resolvedCoverUrls,
);
const fallbackCoverImage =
resolvedCoverUrls?.get(rawFallbackCoverImage) ?? rawFallbackCoverImage;
const previewKey = `${buildPublicGalleryCardKey(entry)}:${position ?? 'preview'}`;
const shouldLockCoverImage = position === 'cover';
const [lockedCoverImage, setLockedCoverImage] = useState({
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
});
useEffect(() => {
setLockedCoverImage((currentValue) => {
if (shouldLockCoverImage) {
return currentValue;
}
return {
key: previewKey,
imageSrc: resolvedCoverImage,
fallbackSrc: fallbackCoverImage,
};
});
}, [
fallbackCoverImage,
previewKey,
resolvedCoverImage,
shouldLockCoverImage,
]);
const coverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: resolvedCoverImage;
const displayFallbackCoverImage = shouldLockCoverImage
? lockedCoverImage.imageSrc
: fallbackCoverImage;
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePlatformPublicWorkKind(entry);
const previewClassName = `platform-recommend-runtime-preview ${
position === 'cover' ? 'platform-recommend-runtime-preview--cover' : ''
}`;
return (
<div
className={previewClassName}
aria-hidden="true"
data-preview-position={position}
>
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={displayFallbackCoverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.08),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-runtime-preview__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-runtime-preview__title">
{displayName}
</span>
</div>
</div>
);
}
function RecommendRuntimeCover({
entry,
className = '',
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
className?: string;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
return (
<div
className={`platform-recommend-runtime-cover ${className}`}
aria-hidden="true"
>
<RecommendRuntimePreviewCard
key={buildPublicGalleryCardKey(entry)}
entry={entry}
position="cover"
resolvedCoverUrls={resolvedCoverUrls}
/>
<div className="platform-recommend-runtime-loading" />
</div>
);
}
function RecommendRuntimeReadyProbe({
rootRef,
onReady,
}: {
rootRef: RefObject<HTMLDivElement | null>;
onReady: () => void;
}) {
useEffect(() => {
const abortController = new AbortController();
void readyRecommendRuntime(rootRef.current, abortController.signal).then(
(ready) => {
if (ready) {
onReady();
}
},
);
return () => abortController.abort();
}, [onReady, rootRef]);
return null;
}
function RecommendRuntimeVisual({
entry,
runtimeContent,
isStarting,
isRuntimeReady,
resolvedCoverUrls,
}: {
entry: PlatformPublicGalleryCard;
runtimeContent?: ReactNode;
isStarting: boolean;
isRuntimeReady: boolean;
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
}) {
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
const [isCoverMinVisible, setIsCoverMinVisible] = useState(true);
const activeEntryKey = buildPublicGalleryCardKey(entry);
const previousEntryKeyRef = useRef(activeEntryKey);
const runtimeVisibilityRef = useRef({
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted: false,
isRuntimeReady,
isStarting,
});
const runtimeViewportRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
runtimeVisibilityRef.current = {
hasRuntimeContent: Boolean(runtimeContent),
isRuntimeMounted,
isRuntimeReady,
isStarting,
};
}, [isRuntimeMounted, isRuntimeReady, isStarting, runtimeContent]);
useEffect(() => {
const currentRuntimeVisibility = runtimeVisibilityRef.current;
if (
previousEntryKeyRef.current !== activeEntryKey &&
currentRuntimeVisibility.hasRuntimeContent &&
currentRuntimeVisibility.isRuntimeMounted &&
currentRuntimeVisibility.isRuntimeReady &&
!currentRuntimeVisibility.isStarting
) {
setIsCoverMinVisible(false);
return undefined;
}
setIsCoverMinVisible(true);
const timeoutId = window.setTimeout(() => {
setIsCoverMinVisible(false);
}, RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS);
return () => window.clearTimeout(timeoutId);
}, [activeEntryKey]);
useEffect(() => {
if (previousEntryKeyRef.current === activeEntryKey) {
return;
}
previousEntryKeyRef.current = activeEntryKey;
setIsRuntimeMounted((currentValue) => {
// 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品;
// 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。
if (currentValue && !isStarting && isRuntimeReady) {
return currentValue;
}
return false;
});
}, [activeEntryKey, isRuntimeReady, isStarting]);
const handleRuntimeReady = useCallback(() => {
if (!isStarting && isRuntimeReady) {
setIsRuntimeMounted(true);
}
}, [isRuntimeReady, isStarting]);
const shouldShowCover =
isCoverMinVisible ||
!runtimeContent ||
isStarting ||
!isRuntimeReady ||
!isRuntimeMounted;
return (
<div className="platform-recommend-runtime-visual">
{runtimeContent ? (
<Suspense fallback={null}>
<div
ref={runtimeViewportRef}
className="platform-recommend-runtime-viewport"
aria-hidden={shouldShowCover}
>
{runtimeContent}
<RecommendRuntimeReadyProbe
key={activeEntryKey}
rootRef={runtimeViewportRef}
onReady={handleRuntimeReady}
/>
</div>
</Suspense>
) : null}
<RecommendRuntimeCover
entry={entry}
resolvedCoverUrls={resolvedCoverUrls}
className={
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
}
/>
</div>
);
}
function RecommendSwipeCard({
entry,
authorAvatarUrl,
authorSummary,
isActive,
visual,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
isActive: boolean;
visual: ReactNode;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
data-active={isActive ? 'true' : 'false'}
>
<div className="platform-recommend-swipe-card__visual">{visual}</div>
<div
className="platform-recommend-swipe-card__meta"
data-recommend-swipe-zone={isActive ? 'true' : 'false'}
>
<RecommendRuntimeMeta
entry={entry}
authorAvatarUrl={authorAvatarUrl}
authorSummary={authorSummary}
isActive={isActive}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
authorSummary,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
authorSummary?: PublicUserSummary | null;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = resolvePlatformWorkAuthorDisplayName(entry, authorSummary);
const authorAvatarLabel = formatPlatformPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
className={`platform-recommend-work-meta ${
isActive
? 'platform-recommend-work-meta--active'
: 'platform-recommend-work-meta--preview'
}`}
aria-label={`${entry.worldName} 作品信息`}
aria-hidden={!isActive}
data-active={isActive ? 'true' : 'false'}
onPointerDown={(event) => {
onDragPointerDown?.(event);
}}
onPointerMove={onDragPointerMove}
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__row">
<div className="platform-recommend-work-meta__identity">
<span
className="platform-recommend-work-meta__avatar"
aria-hidden="true"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="platform-recommend-work-meta__text">
<span className="platform-recommend-work-meta__author">
{authorName}
</span>
<span className="platform-recommend-work-meta__title">
{displayName}
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
aria-label={`点赞 ${formatPlatformCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatPlatformCompactCount(likeCount)} 个赞`}
>
{formatPlatformCompactCount(likeCount)}
</span>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
aria-label={`改造 ${formatPlatformCompactCount(remixCount)}`}
title="改造"
>
<GitFork className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</section>
);
}
function SaveArchiveCard({
entry,
onClick,
loading = false,
}: {
entry: ProfileSaveArchiveSummary;
onClick: () => void;
loading?: boolean;
}) {
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
return (
<button
type="button"
onClick={onClick}
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap justify-end gap-2">
<PlatformPillBadge
tone="darkNeutral"
size="xs"
className="font-medium text-[var(--platform-text-base)]"
>
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</PlatformPillBadge>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.35rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-2xl">
{displayName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-2 break-words text-sm font-semibold text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
<span>{loading ? '正在恢复' : '继续游玩'}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</div>
</div>
<SaveArchivePreview
entry={entry}
className="aspect-square w-[6.5rem] self-start sm:w-[7.5rem]"
/>
</div>
</div>
</button>
);
}
function PlatformTabButton({
active,
label,
icon: Icon,
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-bottom-nav__button-content">
<span
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
>
<Icon className="platform-bottom-nav__icon" />
{showDot ? (
<span aria-hidden="true" className="platform-nav-unread-dot" />
) : null}
</span>
<span className="platform-bottom-nav__label">{label}</span>
{active ? (
<span
aria-hidden="true"
className="platform-bottom-nav__active-mark"
/>
) : null}
</span>
</button>
);
}
function DesktopTabButton({
active,
label,
icon: Icon,
onClick,
emphasized = false,
showDot = false,
disabled = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
disabled?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''} disabled:cursor-not-allowed disabled:opacity-55`}
>
<span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
{showDot ? (
<span aria-hidden="true" className="platform-nav-unread-dot" />
) : null}
</span>
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
{label}
</span>
</button>
);
}
function PlatformTabPanel({
tab,
activeTab,
children,
}: {
tab: PlatformHomeTab;
activeTab: PlatformHomeTab;
children: ReactNode;
}) {
const active = activeTab === tab;
// 主 Tab 保持挂载,只切换可见性,避免重页面卸载重建造成首帧闪烁。
return (
<section
id={`platform-tab-panel-${tab}`}
aria-hidden={!active}
className={`platform-tab-panel ${active ? 'platform-tab-panel--active' : 'platform-tab-panel--hidden'}`}
>
{children}
</section>
);
}
function DesktopTrendingItem({
entry,
rank,
onClick,
}: {
entry: PlatformPublicGalleryCard;
rank: number;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
return (
<button
type="button"
onClick={onClick}
className="platform-desktop-trending-item flex items-center gap-4 px-4 py-4 text-left"
>
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.26))]" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span className="truncate">
{describePlatformPublicWorkKind(entry)}
</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length > 0 ? (
tags.map((tag, index) => (
<PlatformPillBadge
key={`${entry.profileId}-trend-tag-${index}-${tag}`}
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{tag}
</PlatformPillBadge>
))
) : (
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-2.5 tracking-[0.18em]"
>
{isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function PlatformRankingItem({
entry,
rank,
metric,
onClick,
}: {
entry: PlatformPublicGalleryCard;
rank: number;
metric: PlatformRankingMetric;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
return (
<button
type="button"
onClick={onClick}
className="platform-ranking-item w-full text-left"
>
<div className="platform-ranking-item__rank">{rank}</div>
<div className="platform-ranking-item__cover">
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.24),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-semibold text-[var(--platform-text-soft)]">
<span className="text-[var(--platform-warm-text)]">
{formatPlatformCompactCount(metric.value)}
</span>
<span>{metric.label}</span>
<span>·</span>
<span>{describePlatformPublicWorkKind(entry)}</span>
</div>
<div className="platform-ranking-item__tags">
{tags.map((tag, index) => (
<span key={`${entry.profileId}-ranking-tag-${index}-${tag}`}>
{tag}
</span>
))}
</div>
</div>
<span className="platform-ranking-item__action">{actionLabel}</span>
</button>
);
}
function PlatformCategoryGameItem({
entry,
categoryTag,
onClick,
}: {
entry: PlatformPublicGalleryCard;
categoryTag: string;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 2);
const metric = getPlatformCategoryPrimaryMetric(entry);
const metaParts = [
describePlatformPublicWorkKind(entry),
...tags.filter((tag) => tag !== categoryTag),
].slice(0, 3);
const actionLabel =
isPuzzleGalleryEntry(entry) || isVisualNovelGalleryEntry(entry)
? '试玩'
: '进入';
const summaryText =
entry.summaryText || entry.subtitle || `${displayName} 正在等待摘要。`;
return (
<button
type="button"
onClick={onClick}
aria-label={`${entry.worldName}${actionLabel}`}
className="platform-category-game-item"
>
<div className="platform-category-game-item__cover">
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.26),transparent_32%),linear-gradient(135deg,rgba(255,118,117,0.44),rgba(89,164,255,0.36))]" />
)}
</div>
<div className="platform-category-game-item__body">
<div className="platform-category-game-item__title-row">
<span className="platform-category-game-item__title">
{displayName}
</span>
<span className="platform-category-game-item__badge">公开</span>
</div>
<div className="platform-category-game-item__meta">
<span className="platform-category-game-item__metric">
<Star className="h-3.5 w-3.5 fill-current" />
<span>{formatPlatformCompactCount(metric.value)}</span>
</span>
<span>{metric.label}</span>
{metaParts.length > 0 ? <span>{metaParts.join(' · ')}</span> : null}
</div>
<div className="platform-category-game-item__summary">
{summaryText}
</div>
</div>
<span className="platform-category-game-item__action">{actionLabel}</span>
</button>
);
}
function PlatformCategoryFilterDialog({
kindFilter,
sortMode,
resultCount,
onKindFilterChange,
onSortModeChange,
onReset,
onClose,
}: {
kindFilter: PlatformCategoryKindFilter;
sortMode: PlatformCategorySortMode;
resultCount: number;
onKindFilterChange: (filter: PlatformCategoryKindFilter) => void;
onSortModeChange: (mode: PlatformCategorySortMode) => void;
onReset: () => void;
onClose: () => void;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-end justify-center px-3 py-4 sm:items-center">
<button
type="button"
aria-label="关闭分类筛选"
className="absolute inset-0"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label="分类筛选"
className="platform-modal-shell platform-category-filter-dialog relative w-full max-w-md overflow-hidden rounded-[1.35rem]"
>
<div className="flex min-w-0 items-center justify-between gap-3 px-4 py-4">
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
分类筛选
</div>
<div className="mt-0.5 text-xs font-semibold text-[var(--platform-text-soft)]">
{resultCount} 个作品
</div>
</div>
<PlatformModalCloseButton
label="关闭"
onClick={onClose}
icon={<XCircle className="h-5 w-5" />}
/>
</div>
<div className="grid gap-4 px-4 pb-4">
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
玩法
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_KIND_FILTERS.map((option) => {
const active = option.id === kindFilter;
return (
<button
key={option.id}
type="button"
onClick={() => onKindFilterChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
</div>
<div className="grid gap-2">
<div className="text-xs font-black text-[var(--platform-text-soft)]">
排序
</div>
<div className="platform-category-filter-dialog__options">
{PLATFORM_CATEGORY_SORT_OPTIONS.map((option) => {
const active = option.id === sortMode;
return (
<button
key={option.id}
type="button"
onClick={() => onSortModeChange(option.id)}
className={`platform-category-filter-dialog__option ${active ? 'platform-category-filter-dialog__option--active' : ''}`}
>
{option.label}
</button>
);
})}
</div>
</div>
</div>
<div className="platform-category-filter-dialog__actions">
<button
type="button"
onClick={onReset}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--secondary"
>
重置
</button>
<button
type="button"
onClick={onClose}
className="platform-category-filter-dialog__action platform-category-filter-dialog__action--primary"
>
完成
</button>
</div>
</div>
</div>
);
}
function PlatformWorkSearchResults({
keyword,
entries,
onOpen,
onClear,
}: {
keyword: string;
entries: PlatformPublicGalleryCard[];
onOpen: (entry: PlatformPublicGalleryCard) => void;
onClear: () => void;
}) {
const trimmedKeyword = keyword.trim();
if (!trimmedKeyword) {
return null;
}
return (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5 sm:px-5 sm:py-4`}>
<div className="flex items-center justify-between gap-3">
<SectionHeader title="搜索结果" detail={`${entries.length} 个作品`} />
<PlatformIconButton
onClick={onClear}
label="清空搜索"
title="清空搜索"
icon={<span aria-hidden="true">×</span>}
/>
</div>
{entries.length > 0 ? (
<div className="grid min-w-0 gap-2.5">
{entries.slice(0, 12).map((entry) => {
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const workCode = getPlatformSearchableWorkIds(entry)[0] ?? '';
const summaryText =
entry.summaryText || entry.subtitle || '等待补充世界摘要。';
return (
<button
key={`${buildPublicGalleryCardKey(entry)}:search-result`}
type="button"
onClick={() => onOpen(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-sm font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 line-clamp-1 text-xs text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
{workCode ? ` · ${workCode}` : ''}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{summaryText}
</div>
</div>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="shrink-0 px-3 tracking-[0.18em]"
>
{describePlatformPublicWorkKind(entry)}
</PlatformPillBadge>
</button>
);
})}
</div>
) : (
<PlatformEmptyState>没有匹配的公开作品。</PlatformEmptyState>
)}
</section>
);
}
async function getPublicWorkAuthorSummary(
authorLookup: PlatformPublicWorkAuthorLookup,
): Promise<PublicUserSummary | null> {
if (authorLookup.source === 'publicUserCode') {
return getPublicAuthUserByCode(authorLookup.value);
}
if (authorLookup.source === 'ownerUserId') {
return getPublicAuthUserById(authorLookup.value);
}
return null;
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function normalizeProfileInviteQueryCode(value: string | null | undefined) {
return (value ?? '')
.trim()
.replace(/[^0-9a-z]/giu, '')
.toUpperCase();
}
function readProfileInviteCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
for (const key of PROFILE_INVITE_QUERY_KEYS) {
const inviteCode = normalizeProfileInviteQueryCode(params.get(key));
if (inviteCode) {
return inviteCode;
}
}
return '';
}
function buildPublicUserCode(user: AuthUser | null | undefined) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
const raw =
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || '00000000';
return `SY-${raw.slice(-8).padStart(8, '0')}`;
}
function getUserAvatarLabel(user: AuthUser | null | undefined) {
return (user?.displayName || '叙').slice(0, 1).toUpperCase();
}
function validateProfileDisplayName(value: string) {
const normalized = value.trim();
if (!normalized) {
return '请输入昵称';
}
const length = Array.from(normalized).length;
if (length < 2 || length > 20) {
return '昵称需要 2 到 20 位';
}
if (!/^[\u4e00-\u9fffa-zA-Z0-9_]+$/u.test(normalized)) {
return '昵称仅支持中文、英文、数字和下划线';
}
return null;
}
function readImageIntrinsicSize(src: string) {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
const image = new Image();
image.onload = () => {
resolve({
width: image.naturalWidth,
height: image.naturalHeight,
});
};
image.onerror = () => reject(new Error('图片读取失败'));
image.src = src;
});
}
function loadAvatarFile(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('图片读取失败'));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
function cropAvatarImage(params: {
source: string;
cropX: number;
cropY: number;
cropSize: number;
}) {
return new Promise<string>((resolve, reject) => {
const image = new Image();
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('头像裁剪失败'));
return;
}
context.drawImage(
image,
params.cropX,
params.cropY,
params.cropSize,
params.cropSize,
0,
0,
AVATAR_OUTPUT_SIZE,
AVATAR_OUTPUT_SIZE,
);
resolve(canvas.toDataURL('image/png'));
};
image.onerror = () => reject(new Error('头像裁剪失败'));
image.src = params.source;
});
}
function ProfileStatCard({
cardKey,
label,
value,
onClick,
icon,
imageSrc,
}: {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
}
function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
function ProfileShortcutButton({
label,
subLabel,
icon,
onClick,
imageSrc,
}: {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick ?? undefined}
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
>
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
</button>
);
}
function ProfileSettingsRow({
label,
icon,
onClick,
}: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-4 w-4" />
</span>
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function ProfileLegalSection({
onOpenDocument,
}: {
onOpenDocument: (documentId: LegalDocumentId) => void;
}) {
return (
<section className="platform-profile-legal-strip" aria-label="法律信息">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className="platform-profile-legal-strip__link"
>
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span
aria-hidden="true"
className="platform-profile-legal-strip__divider"
/>
) : null}
</button>
))}
</div>
<a
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
</section>
);
}
function ProfileReferralUserAvatar({
name,
avatarUrl,
}: {
name: string;
avatarUrl: string | null;
}) {
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
return (
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
) : (
avatarLabel
)}
</span>
);
}
function ProfileNicknameModal({
value,
error,
isSaving,
onChange,
onClose,
onSubmit,
}: {
value: string;
error: string | null;
isSaving: boolean;
onChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="profile-nickname-title"
className="platform-modal-shell platform-remap-surface 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 id="profile-nickname-title" className="text-base font-black">
修改昵称
</div>
<PlatformModalCloseButton
label="关闭昵称修改"
variant="profileCompact"
onClick={onClose}
icon="×"
/>
</div>
<div className="px-5 py-5">
<label className="block">
<span className="sr-only">新昵称</span>
<PlatformTextField
autoFocus
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
maxLength={20}
surface="editorDark"
size="lg"
density="roomy"
className="rounded-2xl border-white/12 bg-white/10 text-[var(--platform-text-strong)] focus:border-[var(--platform-surface-hover-border)]"
placeholder="输入新昵称"
/>
</label>
{error ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
className="mt-3 rounded-2xl border-rose-400/25 text-rose-600"
>
{error}
</PlatformStatusMessage>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<PlatformActionButton tone="secondary" onClick={onClose}>
取消
</PlatformActionButton>
<PlatformActionButton onClick={onSubmit} disabled={isSaving}>
{isSaving ? '保存中' : '保存'}
</PlatformActionButton>
</div>
</div>
</div>
</div>
);
}
function clearWechatPayResultHash() {
if (typeof window === 'undefined') {
return;
}
const rawHash = window.location.hash.replace(/^#/, '');
if (!rawHash.includes('wx_pay_result=')) {
return;
}
const params = new URLSearchParams(rawHash);
params.delete('wx_pay_result');
const nextHash = params.toString();
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`;
window.history.replaceState(null, '', nextUrl);
}
function readWechatPayResultFromHash(): WechatPayResult | null {
if (typeof window === 'undefined') {
return null;
}
const result = new URLSearchParams(
window.location.hash.replace(/^#/, ''),
).get('wx_pay_result');
if (!result) {
return null;
}
const [requestId = '', rawStatus = '', explicitOrderId = '', ...rawErrors] =
result.split(':');
const inferredOrderId = requestId
.replace(/^wechat_pay_/, '')
.replace(/_\d+$/, '')
.trim();
const orderId = explicitOrderId.trim() || inferredOrderId;
const status =
rawStatus === 'success'
? 'success'
: rawStatus === 'cancel'
? 'cancel'
: 'fail';
let errorMessage: string | null = null;
const rawError = rawErrors.join(':');
if (rawError) {
try {
errorMessage = decodeURIComponent(rawError);
} catch (_error) {
errorMessage = rawError;
}
}
return {
requestId,
orderId: orderId || null,
status,
errorMessage,
};
}
function loadWechatJsSdk() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
if (window.wx?.miniProgram?.navigateTo) {
return Promise.resolve(window.wx);
}
return new Promise<NonNullable<Window['wx']>>((resolve, reject) => {
const existingScript = document.querySelector<HTMLScriptElement>(
`script[src="${WECHAT_JS_SDK_URL}"]`,
);
const complete = () => {
if (window.wx?.miniProgram?.navigateTo) {
resolve(window.wx);
} else {
reject(new Error('请在微信小程序内完成支付'));
}
};
if (existingScript) {
existingScript.addEventListener('load', complete, { once: true });
existingScript.addEventListener(
'error',
() => reject(new Error('请在微信小程序内完成支付')),
{ once: true },
);
complete();
return;
}
const script = document.createElement('script');
script.src = WECHAT_JS_SDK_URL;
script.async = true;
script.onload = complete;
script.onerror = () => reject(new Error('请在微信小程序内完成支付'));
document.head.appendChild(script);
});
}
async function requestWechatMiniProgramPayment(
payload:
| WechatMiniProgramPayParams
| WechatMiniProgramVirtualPayParams
| null
| undefined,
orderId: string,
): Promise<void> {
if (!payload) {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const wxBridge = await loadWechatJsSdk();
const miniProgram = wxBridge.miniProgram;
if (!miniProgram || typeof miniProgram.navigateTo !== 'function') {
return Promise.reject(new Error('请在微信小程序内完成支付'));
}
const navigateTo = miniProgram.navigateTo;
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
return new Promise<void>((resolve, reject) => {
navigateTo({
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
success() {
resolve();
},
fail(error) {
console.error('[wechat-pay] navigateTo failed', error);
reject(
error instanceof Error
? error
: new Error('请在微信小程序内完成支付'),
);
},
});
});
}
function waitWechatPayConfirmDelay(delayMs: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
}
async function confirmWechatRechargeOrderUntilSettled(
orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status !== 'pending') {
return latestResponse;
}
for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) {
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status !== 'pending') {
return latestResponse;
}
}
try {
const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId);
return streamedResponse;
} catch {
return latestResponse;
}
}
function useWechatNativeQrCode(codeUrl: string | null) {
const [qrImageUrl, setQrImageUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setQrImageUrl(null);
if (!codeUrl) {
return () => {
cancelled = true;
};
}
void QRCode.toDataURL(codeUrl, {
errorCorrectionLevel: 'M',
margin: 1,
width: WECHAT_NATIVE_PAY_QR_IMAGE_SIZE,
}).then((dataUrl) => {
if (!cancelled) {
setQrImageUrl(dataUrl);
}
});
return () => {
cancelled = true;
};
}, [codeUrl]);
return qrImageUrl;
}
function RechargeProductCard({
product,
submittingProductId,
onBuy,
}: {
product: ProfileRechargeProduct;
submittingProductId: string | null;
onBuy: (product: ProfileRechargeProduct) => void;
}) {
const submitting = submittingProductId === product.productId;
const badgeLabel = product.badgeLabel;
const value = buildRechargeProductValueLabel(product);
return (
<PlatformSubpanel
as="button"
type="button"
surface="platform"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
interactive
radius="sm"
padding="none"
className="platform-interactive-card relative min-h-[7.25rem] px-3.5 py-3.5 text-left"
>
{badgeLabel ? (
<PlatformPillBadge
tone="warning"
size="xxs"
className="absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 tracking-[0.18em]"
>
{badgeLabel}
</PlatformPillBadge>
) : null}
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
{product.title}
</div>
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
{value}
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
{formatRechargePrice(product.priceCents)}
</span>
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
{submitting ? '处理中' : '购买'}
</span>
</div>
</PlatformSubpanel>
);
}
function ProfileRechargeModal({
center,
isLoading,
error,
submittingProductId,
nativePayment,
activeTab,
onTabChange,
onClose,
onRetry,
onBuy,
onConfirmNativePayment,
}: {
center: ProfileRechargeCenterResponse | null;
isLoading: boolean;
error: string | null;
submittingProductId: string | null;
nativePayment: NativeWechatPaymentState | null;
activeTab: RechargeTab;
onTabChange: (tab: RechargeTab) => void;
onClose: () => void;
onRetry: () => void;
onBuy: (product: ProfileRechargeProduct) => void;
onConfirmNativePayment: () => void;
}) {
const nativeQrImageUrl = useWechatNativeQrCode(
nativePayment?.codeUrl ?? null,
);
const products =
activeTab === 'points'
? (center?.pointProducts ?? [])
: (center?.membershipProducts ?? []);
const memberLabel = buildMembershipLabel(
center?.membership,
formatSnapshotTime,
);
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-[34rem] overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-black">账户充值</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{center
? `${center.walletBalance}泥点 · ${memberLabel}`
: '读取中'}
</div>
</div>
<PlatformModalCloseButton
label="关闭账户充值"
onClick={onClose}
icon="×"
/>
</div>
<div className="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onTabChange('points')}
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
>
泥点充值
</button>
<button
type="button"
onClick={() => onTabChange('membership')}
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
>
会员卡
</button>
</div>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
/>
))}
</div>
) : products.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{products.map((product) => (
<RechargeProductCard
key={product.productId}
product={product}
submittingProductId={submittingProductId}
onBuy={onBuy}
/>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-4"
>
暂无可购买套餐
</PlatformEmptyState>
)}
{nativePayment ? (
<PlatformSubpanel
as="div"
radius="sm"
padding="md"
className="mt-4 text-center"
>
<div className="text-sm font-black">微信扫码支付</div>
<div className="mx-auto mt-3 flex h-[180px] w-[180px] items-center justify-center rounded-xl bg-white p-2">
{nativeQrImageUrl ? (
<img
src={nativeQrImageUrl}
alt="微信 Native 支付二维码"
className="h-full w-full"
/>
) : (
<span className="text-xs font-semibold text-slate-500">
生成中
</span>
)}
</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-4 disabled:cursor-wait"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</PlatformActionButton>
</PlatformSubpanel>
) : null}
</div>
</div>
</div>
);
}
function RechargePaymentResultModal({
result,
onClose,
}: {
result: RechargePaymentResult;
onClose: () => void;
}) {
const Icon =
result.kind === 'success'
? CheckCircle2
: result.kind === 'cancel'
? XCircle
: AlertCircle;
const iconClass =
result.kind === 'success'
? 'text-[var(--platform-success-text)]'
: result.kind === 'cancel'
? 'text-[var(--platform-text-soft)]'
: 'text-[var(--platform-button-danger-text)]';
return (
<div className="platform-modal-backdrop fixed inset-0 z-[90] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="recharge-payment-result-title"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="px-5 pb-5 pt-6 text-center">
<div
className={`mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 ${iconClass}`}
>
<Icon className="h-8 w-8" aria-hidden="true" />
</div>
<div
id="recharge-payment-result-title"
className="mt-4 text-xl font-black text-[var(--platform-text-strong)]"
>
{result.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
{result.message}
</div>
<PlatformActionButton
surface="profile"
fullWidth
size="md"
className="mt-5"
onClick={onClose}
>
知道了
</PlatformActionButton>
</div>
</div>
</div>
);
}
function RechargePaymentConfirmationMask({ orderId }: { orderId: string }) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[95] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-label="正在确认支付"
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.4rem]"
>
<div className="px-5 pb-5 pt-6 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-white/10 text-[var(--platform-accent)]">
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
</div>
<div className="mt-4 text-xl font-black text-[var(--platform-text-strong)]">
正在确认支付
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-soft)]">
订单 {orderId} 正在同步到账状态,请先停留在当前页面。
</div>
</div>
</div>
</div>
);
}
function WalletLedgerModal({
ledger,
fallbackBalance,
isLoading,
error,
onClose,
onRetry,
}: {
ledger: ProfileWalletLedgerResponse | null;
fallbackBalance: number;
isLoading: boolean;
error: string | null;
onClose: () => void;
onRetry: () => void;
}) {
const walletLedgerPresentation = buildWalletLedgerPresentation(
ledger,
fallbackBalance,
);
const entries = walletLedgerPresentation.entries;
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-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<PlatformModalCloseButton
label="关闭泥点账单"
variant="floating"
onClick={onClose}
className="bg-white/78"
icon="×"
/>
<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]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black">泥点账单</div>
<PlatformPillBadge
tone="profile"
icon={<Coins className="h-3.5 w-3.5 text-[#ff4056]" />}
className="mt-3 bg-white/70"
>
{walletLedgerPresentation.balanceLabel}
</PlatformPillBadge>
</div>
{error ? (
<PlatformStatusMessage
tone="error"
className="mt-4 rounded-xl py-3"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
shape="pill"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : 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 ? (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
暂无账单记录
</PlatformEmptyState>
) : (
<div className="mt-5 space-y-2.5">
{entries.map((entry) => {
return (
<PlatformSubpanel
as="div"
key={entry.id}
surface="flat"
radius="xs"
padding="none"
className="flex items-center justify-between gap-3 px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{entry.sourceLabel}
</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 ${
entry.isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{entry.amountLabel}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceLabel}
</div>
</div>
</PlatformSubpanel>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function ProfileTaskCenterModal({
center,
isLoading,
error,
success,
claimingTaskId,
fallbackBalance,
onClose,
onRetry,
onClaim,
}: {
center: ProfileTaskCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
claimingTaskId: string | null;
fallbackBalance: number;
onClose: () => void;
onRetry: () => void;
onClaim: (taskId: string) => void;
}) {
const tasks = selectProfileTaskCenterTasks(center?.tasks ?? []);
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-md overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-black">每日任务</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{walletBalance}泥点
</div>
</div>
<PlatformModalCloseButton
label="关闭每日任务"
onClick={onClose}
icon="×"
/>
</div>
<div className="space-y-3 px-5 py-5">
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={onRetry}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-white/10"
/>
))}
</div>
) : tasks.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="inline">
暂无任务
</PlatformEmptyState>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = buildProfileTaskProgressLabel(task);
return (
<PlatformSubpanel
as="div"
key={task.taskId}
radius="sm"
padding="md"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{task.title}
</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{progressLabel}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
+{task.rewardPoints}
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{getProfileTaskStatusLabel(task.status)}
</div>
</div>
</div>
<PlatformActionButton
surface="profile"
fullWidth
size="sm"
className="mt-3 disabled:opacity-50"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
>
{getProfileTaskClaimButtonLabel(task, isClaiming)}
</PlatformActionButton>
</PlatformSubpanel>
);
})}
</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-[80] 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>
<PlatformModalCloseButton
label="关闭兑换码"
onClick={onClose}
icon="×"
/>
</div>
<div className="space-y-3 px-5 py-5">
<PlatformTextField
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
size="sm"
density="roomy"
className="uppercase tracking-normal"
placeholder="输入兑换码"
aria-label="兑换码"
autoFocus
/>
<PlatformActionButton
surface="profile"
fullWidth
size="md"
className="disabled:opacity-50"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
>
{isSubmitting ? '兑换中' : '兑换'}
</PlatformActionButton>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{success}
</PlatformStatusMessage>
) : null}
</div>
</div>
</div>
);
}
function ProfileQrScannerModal({
error,
result,
onClose,
onError,
onResult,
}: {
error: string | null;
result: string | null;
onClose: () => void;
onError: (message: string) => void;
onResult: (value: string) => void;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
let isMounted = true;
let scanTimer: number | null = null;
const detectorCtor = getBarcodeDetectorConstructor();
const detector = detectorCtor
? new detectorCtor({ formats: ['qr_code'] })
: null;
const clearScanTimer = () => {
if (scanTimer !== null) {
window.clearTimeout(scanTimer);
scanTimer = null;
}
};
const stopCamera = () => {
const stream = streamRef.current;
streamRef.current = null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
videoElement.srcObject = null;
};
const scanVideo = async () => {
if (!isMounted || !detector || videoElement.readyState < 2) {
if (isMounted && detector) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
return;
}
try {
const codes = await detector.detect(videoElement);
const rawValue = codes[0]?.rawValue?.trim();
if (rawValue) {
clearScanTimer();
stopCamera();
onResult(rawValue);
return;
}
} catch {
onError('扫码识别失败,请调整二维码位置');
}
if (isMounted) {
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
}
};
const startCamera = async () => {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
onError('当前浏览器不支持摄像头扫码');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: 'environment' } },
});
if (!isMounted) {
stream.getTracks().forEach((track) => track.stop());
return;
}
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = stream;
videoElement.srcObject = stream;
await videoElement.play();
if (!detector) {
onError('当前浏览器暂不支持二维码识别');
return;
}
scanTimer = window.setTimeout(scanVideo, PROFILE_QR_SCAN_INTERVAL_MS);
} catch {
onError('无法打开摄像头,请检查权限');
}
};
void startCamera();
return () => {
isMounted = false;
clearScanTimer();
stopCamera();
};
}, [onError, onResult]);
return (
<div
className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6"
role="dialog"
aria-modal="true"
aria-label="扫码"
>
<div className="platform-qr-scanner-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>
<PlatformModalCloseButton
label="关闭扫码"
onClick={onClose}
icon="×"
/>
</div>
<div className="space-y-3 px-5 py-5">
<div className="platform-qr-scanner-modal__viewport">
<video
ref={videoRef}
className="h-full w-full object-cover"
playsInline
muted
/>
<span className="platform-qr-scanner-modal__frame" />
</div>
{result ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
已识别:{result}
</PlatformStatusMessage>
) : error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
</div>
</div>
</div>
);
}
function ProfileReferralModal({
panel,
center,
isLoading,
isSubmittingRedeem,
redeemCode,
copyInviteState,
error,
success,
onClose,
onCopyInvite,
onRedeemCodeChange,
onSubmitRedeemCode,
}: {
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
redeemCode: string;
copyInviteState: CopyFeedbackState;
error: string | null;
success: string | null;
onClose: () => void;
onCopyInvite: () => void;
onRedeemCodeChange: (value: string) => void;
onSubmitRedeemCode: () => void;
}) {
const title =
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
const normalizedRedeemCode = redeemCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
<div className="relative w-full max-w-[24rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<PlatformModalCloseButton
label={`关闭${title}`}
variant="floatingPlain"
onClick={onClose}
icon="×"
/>
<div className="px-5 pb-5 pt-4">
<div className="text-center text-xl font-black">{title}</div>
{panel === 'community' ? (
<div className="mt-5 grid grid-cols-2 gap-3">
{COMMUNITY_QR_CODES.map((qrCode) => (
<PlatformSubpanel
as="div"
key={qrCode.label}
surface="flat"
radius="xs"
padding="xs"
className="text-center"
>
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
<img
src={qrCode.src}
alt={qrCode.alt}
className="h-full w-full object-contain"
loading="lazy"
decoding="async"
/>
</div>
<div className="mt-2 text-sm font-bold text-zinc-700">
{qrCode.label}
</div>
</PlatformSubpanel>
))}
</div>
) : panel === 'redeem' ? (
isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-12 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-11 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : center?.hasRedeemedCode ? (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5"
>
已填写邀请码
</PlatformEmptyState>
) : (
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<PlatformTextField
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
size="lg"
density="roomy"
tone="rose"
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<PlatformActionButton
type="submit"
surface="profile"
fullWidth
size="md"
className="rounded-xl"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
>
{isSubmittingRedeem ? '提交中' : '提交'}
</PlatformActionButton>
</form>
)
) : isLoading ? (
<div className="mt-5 space-y-3">
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div>
) : (
<div className="mt-5 space-y-3">
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="md"
className="text-center"
>
<PlatformFieldLabel
variant="section"
className="block text-[11px] text-zinc-500"
>
邀请码
</PlatformFieldLabel>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</PlatformSubpanel>
<PlatformStatusMessage
tone="warning"
surface="profile"
size="md"
className="space-y-0.5 px-3.5 font-semibold"
>
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div>每日最多获得十次邀请奖励。</div>
</PlatformStatusMessage>
<CopyFeedbackButton
state={copyInviteState}
onClick={onCopyInvite}
disabled={!center?.inviteCode}
idleLabel="复制邀请"
copiedLabel="已复制"
failedLabel="复制失败"
idleIcon={<Copy className="h-4 w-4" />}
actionSurface="profile"
actionSize="md"
actionFullWidth
className="gap-2 rounded-xl"
/>
<PlatformSubpanel
as="div"
surface="flat"
radius="xs"
padding="sm"
>
<PlatformFieldLabel
variant="section"
className="block text-zinc-900"
>
成功邀请
</PlatformFieldLabel>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<PlatformSubpanel
as="div"
key={`${user.userId}-${user.boundAt}`}
surface="soft"
radius="xs"
padding="row"
className="flex items-center gap-3"
>
<ProfileReferralUserAvatar
name={user.displayName}
avatarUrl={user.avatarUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-bold text-zinc-900">
{user.displayName || '玩家'}
</div>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="compact"
className="mt-3 text-center text-xs font-semibold leading-normal"
>
暂无成功邀请
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
)}
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{success ? (
<PlatformStatusMessage tone="success" className="mt-4">
{success}
</PlatformStatusMessage>
) : null}
</div>
</div>
</div>
);
}
function ProfileSaveArchivesModal({
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onResumeSave,
}: {
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
}) {
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-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<PlatformModalCloseButton
label="关闭存档"
variant="floating"
onClick={onClose}
icon="×"
/>
<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]">
SAVES
</div>
<div className="mt-1 text-2xl font-black">存档</div>
</div>
{saveError ? (
<PlatformStatusMessage tone="error" className="mt-4">
{saveError}
</PlatformStatusMessage>
) : null}
{saveEntries.length > 0 ? (
<div className="mt-5 grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:profile-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5"
>
暂无存档
</PlatformEmptyState>
)}
</div>
</div>
</div>
);
}
function ProfilePlayedWorksModal({
stats,
isLoading,
error,
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onOpenWork,
onResumeSave,
}: {
stats: ProfilePlayStatsResponse | null;
isLoading: boolean;
error: string | null;
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onOpenWork?: (work: ProfilePlayedWorkSummary) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
}) {
const playedWorks = stats?.playedWorks ?? [];
const hasArchiveEntries = saveEntries.length > 0;
const hasPlayedWorks = playedWorks.length > 0;
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-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<PlatformModalCloseButton
label="关闭玩过"
variant="floating"
onClick={onClose}
icon="×"
/>
<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>
<PlatformPillBadge
tone="profile"
icon={<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />}
className="mt-2"
>
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
</PlatformPillBadge>
</div>
{error ? (
<PlatformStatusMessage tone="error" className="mt-4">
{error}
</PlatformStatusMessage>
) : null}
{saveError ? (
<PlatformStatusMessage tone="error" className="mt-4">
{saveError}
</PlatformStatusMessage>
) : 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>
) : hasArchiveEntries || hasPlayedWorks ? (
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
可继续
</PlatformFieldLabel>
<div className="grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:played-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
</section>
) : null}
{hasPlayedWorks ? (
<section>
<PlatformFieldLabel variant="section" className="mb-2 block">
玩过
</PlatformFieldLabel>
<div className="space-y-3">
{playedWorks.map((work) => (
<PlatformSubpanel
as="button"
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
surface="flat"
radius="sm"
padding="md"
interactive
className="w-full hover:border-[#ff4056]"
>
<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>
<PlatformPillBadge
tone="profileAccent"
size="xs"
className="shrink-0 border-transparent"
>
{formatPlayedWorkType(work.worldType)}
</PlatformPillBadge>
</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>
</PlatformSubpanel>
))}
</div>
</section>
) : null}
</div>
) : (
<PlatformEmptyState
surface="subpanel"
size="inline"
tone="base"
className="mt-5 text-left"
>
暂无玩过
</PlatformEmptyState>
)}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
isDesktopLayout: isDesktopLayoutProp,
onTabChange,
saveEntries,
saveError,
featuredEntries,
latestEntries,
myEntries,
historyEntries,
profileDashboard,
isLoadingPlatform,
isLoadingDashboard,
isResumingSaveWorldKey,
platformError,
dashboardError,
onResumeSave,
onOpenCreateTypePicker,
onOpenGalleryDetail,
onOpenChildMotionDemo,
onOpenBabyLoveDrawing,
onOpenRecommendGalleryDetail,
recommendRuntimeContent,
activeRecommendEntryKey = null,
isStartingRecommendEntry = false,
isRecommendRuntimeReady = false,
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onShareRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
onSearchPublicCode,
isSearchingPublicCode = false,
onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onOpenFeedback,
onRechargeSuccess,
profileTaskRefreshKey = 0,
createTabContent,
draftTabContent,
hasUnreadDraftUpdate = false,
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const showRechargeEntry = shouldShowRechargeEntry();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [mobileSearchKeyword, setMobileSearchKeyword] = useState('');
const [activeWorkSearchKeyword, setActiveWorkSearchKeyword] = useState('');
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 [isRechargeOpen, setIsRechargeOpen] = useState(false);
const [rechargeCenter, setRechargeCenter] =
useState<ProfileRechargeCenterResponse | null>(null);
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
const [rechargeError, setRechargeError] = useState<string | null>(null);
const [rechargePaymentResult, setRechargePaymentResult] =
useState<RechargePaymentResult | null>(null);
const [
wechatRechargeOrderConfirmationState,
setWechatRechargeOrderConfirmationState,
] = useState<WechatRechargeOrderConfirmationState | null>(null);
const [nativeWechatPayment, setNativeWechatPayment] =
useState<NativeWechatPaymentState | null>(null);
const [activeRechargeTab, setActiveRechargeTab] =
useState<RechargeTab>('points');
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 [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
const [taskCenter, setTaskCenter] =
useState<ProfileTaskCenterResponse | null>(null);
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const taskCenterRequestIdRef = useRef(0);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
const [qrScannerError, setQrScannerError] = useState<string | null>(null);
const [qrScannerResult, setQrScannerResult] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isReferralCenterInitialized, setIsReferralCenterInitialized] =
useState(false);
const pendingProfileInviteCode = useMemo(
() =>
typeof window === 'undefined'
? ''
: readProfileInviteCodeFromLocationSearch(window.location.search),
[],
);
const promptedLoginForInviteQueryRef = useRef(false);
const autoOpenedInviteQueryRef = useRef(false);
const [referralRedeemCode, setReferralRedeemCode] = useState(
pendingProfileInviteCode,
);
const [isSubmittingReferralRedeem, setIsSubmittingReferralRedeem] =
useState(false);
const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const { copyState: inviteCopyState, copyText: copyInviteText } =
useCopyFeedback();
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [categoryKindFilter, setCategoryKindFilter] =
useState<PlatformCategoryKindFilter>(DEFAULT_PLATFORM_CATEGORY_KIND_FILTER);
const [categorySortMode, setCategorySortMode] =
useState<PlatformCategorySortMode>(DEFAULT_PLATFORM_CATEGORY_SORT_MODE);
const [isCategoryFilterPanelOpen, setIsCategoryFilterPanelOpen] =
useState(false);
const [discoverChannel, setDiscoverChannel] =
useState<DiscoverChannel>('recommend');
const mobileDiscoverFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const hasManualCategoryTagSelectionRef = useRef(false);
const pendingPublicAuthorKeysRef = useRef<Set<string>>(new Set());
const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState<
Record<string, PublicUserSummary | null>
>({});
const [activeRankingTab, setActiveRankingTab] = useState<PlatformRankingTab>(
DEFAULT_PLATFORM_RANKING_TAB,
);
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
const { copyState: profileCopyState, copyText: copyProfileText } =
useCopyFeedback();
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const [isNicknameModalOpen, setIsNicknameModalOpen] = useState(false);
const [nicknameInput, setNicknameInput] = useState('');
const [nicknameError, setNicknameError] = useState<string | null>(null);
const [isSavingNickname, setIsSavingNickname] = useState(false);
const [avatarSource, setAvatarSource] = useState<string | null>(null);
const [avatarImageSize, setAvatarImageSize] = useState<{
width: number;
height: number;
} | null>(null);
const [avatarCrop, setAvatarCrop] = useState<SquareImageCropRect>({
x: 0,
y: 0,
size: 1,
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const isAuthenticated = Boolean(authUi?.user);
const edutainmentEntryEnabled = isEdutainmentEntryEnabled();
const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout);
const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout;
const openRecommendGalleryDetail =
onOpenRecommendGalleryDetail ?? onOpenGalleryDetail;
const generalFeaturedEntries = useMemo(
() => filterGeneralPublicWorks(featuredEntries),
[featuredEntries],
);
const featuredShelf = useMemo(
() => generalFeaturedEntries.slice(0, 6),
[generalFeaturedEntries],
);
const generalLatestEntries = useMemo(
() => filterGeneralPublicWorks(latestEntries),
[latestEntries],
);
const allEdutainmentEntries = useMemo(
() => filterEdutainmentPublicWorks([...featuredEntries, ...latestEntries]),
[featuredEntries, latestEntries],
);
const edutainmentEntries = useMemo(
() => (edutainmentEntryEnabled ? allEdutainmentEntries : []),
[allEdutainmentEntries, edutainmentEntryEnabled],
);
const visibleDiscoverChannels = useMemo(
() =>
edutainmentEntryEnabled
? [...DISCOVER_CHANNELS, EDUTAINMENT_DISCOVER_CHANNEL]
: DISCOVER_CHANNELS,
[edutainmentEntryEnabled],
);
const categoryGroups = useMemo(
() =>
buildPublicCategoryGroups(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const publicEntries = useMemo(
() =>
getPlatformPublicEntries(generalFeaturedEntries, generalLatestEntries),
[generalFeaturedEntries, generalLatestEntries],
);
const allPublicEntries = useMemo(
() => getAllPlatformPublicEntries(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const visibleHistoryEntries = useMemo(
() =>
historyEntries.filter((entry) => {
const matchedPublicWork = findPublicWorkForHistoryEntry(
entry,
allPublicEntries,
);
return !matchedPublicWork || canExposePublicWork(matchedPublicWork);
}),
[allPublicEntries, historyEntries],
);
const workSearchResults = useMemo(
() =>
filterPlatformWorkSearchResults(publicEntries, activeWorkSearchKeyword),
[activeWorkSearchKeyword, publicEntries],
);
const getPublicEntryAuthorAvatarUrl = useCallback(
(entry: PlatformPublicGalleryCard) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (!authorLookup) {
return null;
}
return (
publicAuthorSummariesByKey[authorLookup.key]?.avatarUrl?.trim() || null
);
},
[publicAuthorSummariesByKey],
);
const getPublicEntryAuthorSummary = useCallback(
(entry: PlatformPublicGalleryCard) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (!authorLookup) {
return null;
}
return publicAuthorSummariesByKey[authorLookup.key] ?? null;
},
[publicAuthorSummariesByKey],
);
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
null;
const activeCategoryEntries = useMemo(() => {
if (!activeCategoryGroup) {
return [];
}
return sortPlatformCategoryEntries(
activeCategoryGroup.entries.filter((entry) =>
matchesPlatformCategoryKindFilter(entry, categoryKindFilter),
),
categorySortMode,
);
}, [activeCategoryGroup, categoryKindFilter, categorySortMode]);
const activeCategoryRawCount = activeCategoryGroup?.entries.length ?? 0;
const activeCategoryFilterLabel =
getPlatformCategoryKindFilterOption(categoryKindFilter).label;
const activeCategorySortLabel =
getPlatformCategorySortOption(categorySortMode).label;
const activeCategoryFilterCount = activeCategoryEntries.length;
const categoryFilterApplied =
categoryKindFilter !== DEFAULT_PLATFORM_CATEGORY_KIND_FILTER;
const visibleTabs = useMemo<PlatformHomeTab[]>(
() =>
isAuthenticated
? ['home', 'category', 'create', 'saves', 'profile']
: ['home', 'create', 'category'],
[isAuthenticated],
);
const publicUserCode = buildPublicUserCode(authUi?.user);
const avatarLabel = getUserAvatarLabel(authUi?.user);
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const activeLegalDocument = activeLegalDocumentId
? getLegalDocument(activeLegalDocumentId)
: null;
const profileDashboardPresentation = useMemo(
() => buildProfileDashboardPresentation(profileDashboard),
[profileDashboard],
);
const remainingNarrativeCoins = profileDashboardPresentation.walletBalance;
const profileTaskCardSummary = useMemo(
() => buildProfileTaskCardSummary(taskCenter),
[taskCenter],
);
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
}
: {
home: Gamepad2,
category: Compass,
create: Sparkles,
saves: Pencil,
profile: UserRound,
};
const tabLabels = {
home: '推荐',
category: '发现',
create: '创作',
saves: '草稿',
profile: '我的',
} as const;
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange(isAuthenticated ? 'home' : 'category');
}
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
useEffect(() => {
if (
!visibleDiscoverChannels.some((channel) => channel.id === discoverChannel)
) {
setDiscoverChannel('recommend');
}
}, [discoverChannel, visibleDiscoverChannels]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
if (currentTabs.has(activeTab)) {
return currentTabs;
}
const nextTabs = new Set(currentTabs);
nextTabs.add(activeTab);
return nextTabs;
});
}, [activeTab]);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup = categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
const authorLookupsByKey = new Map<
string,
PlatformPublicWorkAuthorLookup
>();
publicEntries.forEach((entry) => {
const authorLookup = resolvePlatformPublicWorkAuthorLookup(entry);
if (authorLookup) {
authorLookupsByKey.set(authorLookup.key, authorLookup);
}
});
const missingAuthorLookups = Array.from(authorLookupsByKey.values()).filter(
(authorLookup) =>
!(authorLookup.key in publicAuthorSummariesByKey) &&
!pendingPublicAuthorKeysRef.current.has(authorLookup.key),
);
if (missingAuthorLookups.length === 0) {
return undefined;
}
let cancelled = false;
missingAuthorLookups.forEach((authorLookup) => {
pendingPublicAuthorKeysRef.current.add(authorLookup.key);
});
// 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
void Promise.all(
missingAuthorLookups.map(async (authorLookup) => {
try {
const author = await getPublicWorkAuthorSummary(authorLookup);
return [authorLookup.key, author] as const;
} catch {
return [authorLookup.key, null] as const;
} finally {
pendingPublicAuthorKeysRef.current.delete(authorLookup.key);
}
}),
).then((results) => {
if (cancelled) {
return;
}
setPublicAuthorSummariesByKey((currentSummaries) => {
let changed = false;
const nextSummaries = { ...currentSummaries };
results.forEach(([authorLookupKey, author]) => {
if (authorLookupKey in nextSummaries) {
return;
}
nextSummaries[authorLookupKey] = author;
changed = true;
});
return changed ? nextSummaries : currentSummaries;
});
});
return () => {
cancelled = true;
};
}, [publicAuthorSummariesByKey, publicEntries]);
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
return;
}
authUi?.openLoginModal();
};
useEffect(() => {
if (!pendingProfileInviteCode || autoOpenedInviteQueryRef.current) {
return;
}
if (!authUi?.user) {
if (!promptedLoginForInviteQueryRef.current) {
promptedLoginForInviteQueryRef.current = true;
authUi?.openLoginModal();
}
return;
}
autoOpenedInviteQueryRef.current = true;
setReferralRedeemCode(pendingProfileInviteCode);
setReferralError(null);
setReferralSuccess(null);
setProfilePopupPanel('redeem');
}, [authUi, pendingProfileInviteCode]);
const copyProfilePublicUserCode = () => {
void copyProfileText(publicUserCode);
};
const openNicknameModal = () => {
const user = authUi?.user;
if (!user) {
authUi?.openLoginModal();
return;
}
setNicknameInput(user.displayName);
setNicknameError(null);
setIsNicknameModalOpen(true);
};
const submitNickname = () => {
if (!authUi?.user || isSavingNickname) {
return;
}
const validationError = validateProfileDisplayName(nicknameInput);
if (validationError) {
setNicknameError(validationError);
return;
}
const nextName = nicknameInput.trim();
setIsSavingNickname(true);
setNicknameError(null);
void updateAuthProfile({ displayName: nextName })
.then((nextUser) => {
authUi.setCurrentUser(nextUser);
setIsNicknameModalOpen(false);
})
.catch((error: unknown) => {
setNicknameError(
error instanceof Error ? error.message : '昵称保存失败',
);
})
.finally(() => setIsSavingNickname(false));
};
const openAvatarPicker = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setAvatarError(null);
avatarFileInputRef.current?.click();
};
const handleAvatarFileChange = (file: File | null) => {
if (avatarFileInputRef.current) {
avatarFileInputRef.current.value = '';
}
if (!file) {
return;
}
if (!AVATAR_ALLOWED_TYPES.has(file.type)) {
setAvatarError('头像仅支持 jpg、png、webp');
return;
}
if (file.size > AVATAR_MAX_FILE_SIZE) {
setAvatarError('头像图片不能超过 5MB');
return;
}
setAvatarError(null);
void loadAvatarFile(file)
.then(async (source) => {
const imageSize = await readImageIntrinsicSize(source);
setAvatarSource(source);
setAvatarImageSize(imageSize);
setAvatarCrop(buildCenteredSquareImageCropRect(imageSize));
})
.catch((error: unknown) => {
setAvatarError(
error instanceof Error ? error.message : '头像图片读取失败',
);
});
};
const updateAvatarCrop = useCallback(
(nextCrop: SquareImageCropRect) => {
if (!avatarImageSize) {
return;
}
setAvatarCrop(clampSquareImageCropRect(avatarImageSize, nextCrop));
},
[avatarImageSize],
);
const submitAvatar = () => {
if (
!avatarSource ||
!avatarImageSize ||
avatarCrop.size <= 0 ||
isSavingAvatar
) {
return;
}
setIsSavingAvatar(true);
setAvatarError(null);
void cropAvatarImage({
source: avatarSource,
cropX: avatarCrop.x,
cropY: avatarCrop.y,
cropSize: avatarCrop.size,
})
.then((avatarDataUrl) => updateAuthProfile({ avatarDataUrl }))
.then((nextUser) => {
authUi?.setCurrentUser(nextUser);
setAvatarSource(null);
setAvatarImageSize(null);
})
.catch((error: unknown) => {
setAvatarError(error instanceof Error ? error.message : '头像上传失败');
})
.finally(() => setIsSavingAvatar(false));
};
const loadWalletLedger = () => {
setWalletLedgerError(null);
setIsLoadingWalletLedger(true);
void getRpgProfileWalletLedger()
.then(setWalletLedger)
.catch((error: unknown) => {
setWalletLedger(null);
setWalletLedgerError(
error instanceof Error ? error.message : '读取泥点账单失败',
);
})
.finally(() => setIsLoadingWalletLedger(false));
};
const openWalletLedgerPanel = () => {
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
const loadRechargeCenter = useCallback(() => {
setRechargeError(null);
setIsLoadingRechargeCenter(true);
void getRpgProfileRechargeCenter()
.then(setRechargeCenter)
.catch((error: unknown) => {
setRechargeCenter(null);
setRechargeError(
error instanceof Error ? error.message : '读取账户充值失败',
);
})
.finally(() => setIsLoadingRechargeCenter(false));
}, []);
const refreshRechargeState = useCallback(() => {
loadRechargeCenter();
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
}, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
if (!payResult) {
return false;
}
if (
pendingWechatRechargeOrderIdRef.current &&
payResult.orderId &&
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
) {
return false;
}
if (payResult.status === 'success') {
const orderId =
payResult.orderId || pendingWechatRechargeOrderIdRef.current;
if (!orderId) {
clearWechatPayResultHash();
return true;
}
if (confirmingWechatRechargeOrderIdRef.current === orderId) {
clearWechatPayResultHash();
return true;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setSubmittingRechargeProductId(null);
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付处理中',
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
clearWechatPayResultHash();
})
.catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({
kind: 'pending',
title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
clearWechatPayResultHash();
});
} else if (payResult.status === 'cancel') {
setRechargePaymentResult({
kind: 'cancel',
title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。',
});
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState();
} else {
const detail = payResult.errorMessage
? `微信返回:${payResult.errorMessage}`
: '微信支付没有完成,本次不会入账。';
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: detail,
});
setWechatRechargeOrderConfirmationState(null);
refreshRechargeState();
}
clearWechatPayResultHash();
return true;
}, [onRechargeSuccess, refreshRechargeState]);
const pollWechatPayResultFromHash = useCallback(
() => handleWechatPayResult(),
[handleWechatPayResult],
);
const confirmPendingWechatRechargeOrder = useCallback(() => {
const orderId = pendingWechatRechargeOrderIdRef.current;
if (!orderId || confirmingWechatRechargeOrderIdRef.current === orderId) {
return false;
}
confirmingWechatRechargeOrderIdRef.current = orderId;
setWechatRechargeOrderConfirmationState({ orderId });
setRechargePaymentResult(null);
void confirmWechatRechargeOrderUntilSettled(orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setSubmittingRechargeProductId(null);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付处理中',
message: '正在等待到账状态确认,请稍后查看余额或会员状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
})
.catch(() => {
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
setRechargePaymentResult({
kind: 'pending',
title: '支付处理中',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
});
return true;
}, [onRechargeSuccess]);
const openRechargeModal = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setIsRechargeOpen(true);
loadRechargeCenter();
};
const openRechargeOrRewardCodeModal = () => {
if (showRechargeEntry) {
openRechargeModal();
return;
}
openRewardCodeModal();
};
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
if (submittingRechargeProductId) {
return;
}
const paymentChannel = resolveProfileRechargeProductPaymentChannel(
{ kind: product.kind },
{},
);
setSubmittingRechargeProductId(product.productId);
setRechargeError(null);
setRechargePaymentResult(null);
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
return;
}
if (paymentChannel === WECHAT_H5_PAYMENT_CHANNEL) {
const h5Url = response.wechatH5Payment?.h5Url?.trim();
if (!h5Url) {
throw new Error('微信 H5 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'pending',
title: '正在打开微信支付',
message: '完成支付后返回页面确认到账状态。',
});
redirectToPaymentUrl(h5Url);
return;
}
if (paymentChannel === WECHAT_NATIVE_PAYMENT_CHANNEL) {
const codeUrl = response.wechatNativePayment?.codeUrl?.trim();
if (!codeUrl) {
throw new Error('微信 Native 支付二维码生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
setNativeWechatPayment({
orderId: response.order.orderId,
codeUrl,
isConfirming: false,
});
setSubmittingRechargeProductId(null);
return;
}
throw new Error('充值支付渠道无效');
})
.catch((error: unknown) => {
pendingWechatRechargeOrderIdRef.current = null;
setNativeWechatPayment(null);
setRechargeError(error instanceof Error ? error.message : '充值失败');
setSubmittingRechargeProductId(null);
});
};
const confirmNativeWechatPayment = useCallback(() => {
if (!nativeWechatPayment || nativeWechatPayment.isConfirming) {
return;
}
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: true }
: current,
);
setRechargePaymentResult({
kind: 'pending',
title: '正在确认支付',
message: '正在查询微信支付到账状态。',
});
void confirmWechatRechargeOrderUntilSettled(nativeWechatPayment.orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
},
);
if (isPaid) {
setNativeWechatPayment(null);
pendingWechatRechargeOrderIdRef.current = null;
void onRechargeSuccess?.();
} else {
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
}
})
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '等待微信确认',
message: '暂时没能确认到账状态,请稍后再试。',
});
setNativeWechatPayment((current) =>
current && current.orderId === nativeWechatPayment.orderId
? { ...current, isConfirming: false }
: current,
);
})
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const handleHashChange = () => {
handleWechatPayResult();
};
const handleResume = () => {
if (
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
) {
return;
}
if (!handleWechatPayResult()) {
confirmPendingWechatRechargeOrder();
}
};
window.addEventListener('hashchange', handleHashChange);
window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', handleResume);
handleWechatPayResult();
return () => {
window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('focus', handleResume);
window.removeEventListener('pageshow', handleResume);
document.removeEventListener('visibilitychange', handleResume);
};
}, [confirmPendingWechatRechargeOrder, handleWechatPayResult]);
useEffect(() => {
if (!submittingRechargeProductId || wechatRechargeOrderConfirmationState) {
return undefined;
}
const startedAt = Date.now();
let timer: number | null = null;
const pollPayResult = () => {
if (pollWechatPayResultFromHash()) {
return;
}
if (Date.now() - startedAt >= WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS) {
return;
}
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
};
timer = window.setTimeout(
pollPayResult,
WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS,
);
return () => {
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [
pollWechatPayResultFromHash,
submittingRechargeProductId,
wechatRechargeOrderConfirmationState,
]);
const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then((center) => {
if (requestId === taskCenterRequestIdRef.current) {
setTaskCenter(center);
}
})
.catch((error: unknown) => {
if (requestId !== taskCenterRequestIdRef.current) {
return;
}
setTaskCenter(null);
setTaskCenterError(
error instanceof Error ? error.message : '读取每日任务失败',
);
})
.finally(() => {
if (requestId === taskCenterRequestIdRef.current) {
setIsLoadingTaskCenter(false);
}
});
}, []);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
taskCenterRequestIdRef.current += 1;
setTaskCenter(null);
setTaskCenterError(null);
return;
}
loadTaskCenter();
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
return undefined;
}
let cancelled = false;
let timer: number | null = null;
const scheduleNextReset = () => {
if (cancelled) {
return;
}
timer = window.setTimeout(() => {
void refreshStoredAccessToken({ clearOnFailure: false })
.catch(() => undefined)
.finally(() => {
if (cancelled) {
return;
}
loadTaskCenter();
scheduleNextReset();
});
}, getDelayUntilNextProfileTaskReset());
};
scheduleNextReset();
return () => {
cancelled = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [activeTab, isAuthenticated, loadTaskCenter]);
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
if (!taskCenter) {
loadTaskCenter();
}
};
const openQrScannerPanel = () => {
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setQrScannerError(null);
setQrScannerResult(null);
setIsQrScannerOpen(true);
};
const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true);
setIsReferralCenterInitialized(false);
void getRpgProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
setReferralCenter(null);
setReferralError(
error instanceof Error ? error.message : '读取邀请码失败',
);
})
.finally(() => {
setIsReferralCenterInitialized(true);
setIsLoadingReferral(false);
});
}, []);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
setIsReferralCenterInitialized(false);
setReferralCenter(null);
return;
}
loadReferralCenter();
}, [activeTab, isAuthenticated, loadReferralCenter]);
const openProfilePopupPanel = (panel: ProfileReferralPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
setReferralSuccess(null);
if (panel === 'redeem') {
setReferralRedeemCode(pendingProfileInviteCode);
}
if (panel === 'community') {
return;
}
if (!isReferralCenterInitialized && !isLoadingReferral) {
loadReferralCenter();
}
};
const copyInviteInfo = () => {
if (!referralCenter?.inviteCode) {
return;
}
const inviteUrl =
typeof window === 'undefined'
? referralCenter.inviteLinkPath
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then(
(copied) => {
setReferralSuccess(copied ? '已复制' : '复制失败');
},
);
};
const submitReferralRedeemCode = () => {
const inviteCode = referralRedeemCode
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
if (isSubmittingReferralRedeem || !inviteCode) {
return;
}
setIsSubmittingReferralRedeem(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCode)
.then((response) => {
setReferralCenter(response.center);
setReferralRedeemCode('');
setReferralSuccess('已填写');
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferralRedeem(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 claimTaskReward = (taskId: string) => {
if (claimingTaskId) {
return;
}
setClaimingTaskId(taskId);
setTaskCenterError(null);
setTaskClaimSuccess(null);
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} 泥点`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setTaskCenterError(
error instanceof Error ? error.message : '领取任务奖励失败',
);
})
.finally(() => setClaimingTaskId(null));
};
const clearWorkSearch = () => {
setActiveWorkSearchKeyword('');
setDesktopSearchKeyword('');
setMobileSearchKeyword('');
};
const updateDesktopSearchKeyword = (value: string) => {
setDesktopSearchKeyword(value);
if (!value.trim()) {
setActiveWorkSearchKeyword('');
}
};
const updateMobileSearchKeyword = (value: string) => {
setMobileSearchKeyword(value);
if (!value.trim()) {
setActiveWorkSearchKeyword('');
}
};
const submitWorkSearch = (keyword: string) => {
const trimmedKeyword = keyword.trim();
if (!trimmedKeyword) {
return;
}
// 中文注释:优先使用首页已经聚合好的公开作品读模型做模糊命中;
// 无本地命中时继续走既有编号直达兜底,避免破坏深链搜索。
const matchedEntries = filterPlatformWorkSearchResults(
publicEntries,
trimmedKeyword,
);
const hiddenEdutainmentMatches = filterPlatformWorkSearchResults(
allEdutainmentEntries,
trimmedKeyword,
);
if (
matchedEntries.length > 0 &&
isExactPublicWorkCodeSearch(matchedEntries, trimmedKeyword) &&
onSearchPublicCode &&
!isSearchingPublicCode
) {
setActiveWorkSearchKeyword('');
void onSearchPublicCode(trimmedKeyword);
return;
}
if (matchedEntries.length > 0) {
setActiveWorkSearchKeyword(trimmedKeyword);
return;
}
if (hiddenEdutainmentMatches.length > 0) {
setActiveWorkSearchKeyword(trimmedKeyword);
return;
}
setActiveWorkSearchKeyword('');
if (!onSearchPublicCode || isSearchingPublicCode) {
return;
}
void onSearchPublicCode(trimmedKeyword);
};
const submitDesktopSearch = () => {
submitWorkSearch(desktopSearchKeyword);
};
const submitMobileSearch = () => {
submitWorkSearch(mobileSearchKeyword);
};
const cycleCategorySortMode = () => {
setCategorySortMode(getNextPlatformCategorySortMode(categorySortMode));
};
const desktopHeroEntry =
featuredShelf[0] ?? generalLatestEntries[0] ?? myEntries[0] ?? null;
const desktopHeroCover = desktopHeroEntry
? resolvePlatformWorldCoverImage(desktopHeroEntry)
: null;
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : generalLatestEntries
).slice(0, 5);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const recommendedFeedEntries = useMemo(
() =>
buildPlatformRecommendFeedEntries(featuredShelf, generalLatestEntries),
[featuredShelf, generalLatestEntries],
);
const desktopRecommendEntries = recommendedFeedEntries;
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(generalLatestEntries),
[generalLatestEntries],
);
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const resolvedRecommendCoverUrls = useResolvedRecommendCoverImages(
recommendedFeedEntries,
);
const discoverFeedEntries = useMemo(() => {
const sourceEntries =
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(generalLatestEntries);
return dedupePlatformPublicGalleryEntries(sourceEntries);
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
const edutainmentFeedEntries = useMemo(
() => dedupePlatformPublicGalleryEntries(edutainmentEntries),
[edutainmentEntries],
);
const mobileFeedCarouselEnabled =
!isDesktopLayout &&
activeTab === 'category' &&
(discoverChannel === 'recommend' || discoverChannel === 'today');
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileDiscoverFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
return undefined;
}
let frameId: number | null = null;
const updateCenteredCard = () => {
frameId = null;
const cards = Array.from(
feedElement.querySelectorAll<HTMLElement>(
'[data-mobile-feed-card-key]',
),
);
const viewportRect = scrollElement.getBoundingClientRect();
const viewportCenterY =
viewportRect.top + Math.max(0, viewportRect.height) / 2;
let closestKey: string | null = null;
let closestDistance = Number.POSITIVE_INFINITY;
cards.forEach((card) => {
const cardKey = card.dataset.mobileFeedCardKey;
if (!cardKey) {
return;
}
const cardRect = card.getBoundingClientRect();
if (
cardRect.bottom <= viewportRect.top ||
cardRect.top >= viewportRect.bottom
) {
return;
}
const cardCenterY = cardRect.top + cardRect.height / 2;
const distance = Math.abs(cardCenterY - viewportCenterY);
if (distance < closestDistance) {
closestDistance = distance;
closestKey = cardKey;
}
});
setMobileCenteredCardKey((current) =>
current === closestKey ? current : closestKey,
);
};
const scheduleUpdate = () => {
if (frameId !== null) {
return;
}
frameId =
typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame(updateCenteredCard)
: window.setTimeout(updateCenteredCard, 0);
};
scheduleUpdate();
scrollElement.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frameId !== null) {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(frameId);
} else {
window.clearTimeout(frameId);
}
}
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [
discoverChannel,
discoverFeedEntries,
activeTab,
mobileFeedCarouselEnabled,
]);
const activeRankingConfig = getPlatformRankingTabConfig(activeRankingTab);
const rankingEntries = useMemo(
() =>
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const recommendFeedWindow = useMemo(
() =>
selectPlatformRecommendFeedWindow(
recommendedFeedEntries,
activeRecommendEntryKey,
),
[activeRecommendEntryKey, recommendedFeedEntries],
);
const activeRecommendEntry = recommendFeedWindow.activeEntry;
const previousRecommendEntry = recommendFeedWindow.previousEntry;
const nextRecommendEntry = recommendFeedWindow.nextEntry;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<RecommendSwipeDirection | null>(null);
const activeRecommendEntryKeyForSelection =
recommendFeedWindow.activeEntryKey;
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
startY: number;
dragging: boolean;
} | null>(null);
const commitRecommendDrag = useCallback(
(direction: RecommendSwipeDirection) => {
if (recommendDragCommitDirection) {
return;
}
setRecommendDragCommitDirection(direction);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
setRecommendDragOffsetY(
resolveRecommendCommitOffset(
direction,
panelHeight,
window.innerHeight,
),
);
window.setTimeout(() => {
if (direction === 1) {
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
} else {
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
}
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
activeRecommendEntryKeyForSelection,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
recommendDragCommitDirection,
],
);
const beginRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
return;
}
recommendDragStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
activeRecommendEntry,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],
);
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
const deltaY = event.clientY - drag.startY;
drag.dragging = drag.dragging || hasRecommendDragStarted(deltaY);
if (!drag.dragging) {
return;
}
event.preventDefault();
const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
setRecommendDragOffsetY(clampRecommendDragOffset(deltaY, cardHeight));
}, []);
const endRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
event.currentTarget.releasePointerCapture?.(drag.pointerId);
recommendDragStartRef.current = null;
const deltaY = event.clientY - drag.startY;
const commitDirection = resolveRecommendDragCommitDirection(deltaY);
if (!commitDirection) {
setRecommendDragOffsetY(0);
return;
}
commitRecommendDrag(commitDirection);
},
[commitRecommendDrag],
);
const cancelRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (drag) {
event.currentTarget.releasePointerCapture?.(drag.pointerId);
}
recommendDragStartRef.current = null;
setRecommendDragOffsetY(0);
},
[],
);
const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties;
const recommendRailClassName = buildRecommendSwipeRailClassName({
offsetY: recommendDragOffsetY,
commitDirection: recommendDragCommitDirection,
});
const selectNextRecommendEntry = useCallback(() => {
if (
shouldAnimateRecommendSwipe({
isAuthenticated,
hasActiveEntry: Boolean(activeRecommendEntry),
entryCount: recommendedFeedEntries.length,
})
) {
commitRecommendDrag(1);
return;
}
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
openRecommendGalleryDetail(leadPublicEntry);
return;
}
onTabChange('category');
};
const mobileRankingPanel: ReactNode = (
<section
className={`${PANEL_SURFACE_CLASS} platform-ranking-panel px-4 py-3.5 sm:px-5 sm:py-4`}
>
<div
className="platform-ranking-tabs flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide"
role="tablist"
aria-label="作品排行"
>
{PLATFORM_RANKING_TABS.map((tab) => {
const active = tab.id === activeRankingTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
onClick={() => setActiveRankingTab(tab.id)}
className={`platform-ranking-tab shrink-0 ${active ? 'platform-ranking-tab--active' : ''}`}
>
{tab.label}
</button>
);
})}
</div>
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : rankingEntries.length > 0 ? (
<div className="mt-3 grid min-w-0 gap-2.5">
{rankingEntries.map((entry, index) => (
<PlatformRankingItem
key={`${buildPublicGalleryCardKey(entry)}:ranking:${activeRankingTab}`}
entry={entry}
rank={index + 1}
metric={getPlatformRankingMetric(entry, activeRankingTab)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>{activeRankingConfig.emptyText}</PlatformEmptyState>
)}
</section>
);
const mobileRecommendContent: ReactNode = (
<div
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{isLoadingPlatform ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">
正在读取公开作品...
</div>
</section>
) : recommendRuntimeError ? (
<section className="platform-recommend-runtime-panel">
<button
type="button"
onClick={() =>
activeRecommendEntry
? openRecommendGalleryDetail(activeRecommendEntry)
: undefined
}
className="platform-recommend-runtime-state platform-recommend-runtime-state--button"
>
{recommendRuntimeError}
</button>
</section>
) : activeRecommendEntry ? (
<div
ref={recommendCardStageRef}
className="platform-recommend-swipe-stage"
>
<div
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
style={recommendRailStyle}
>
{previousRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
<RecommendSwipeCard
entry={previousRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
previousRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
previousRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>
</div>
) : null}
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
<RecommendSwipeCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
activeRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
activeRecommendEntry,
)}
isActive
visual={
<RecommendRuntimeVisual
entry={activeRecommendEntry}
runtimeContent={recommendRuntimeContent}
isStarting={isStartingRecommendEntry}
isRuntimeReady={isRecommendRuntimeReady}
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>
{nextRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
<RecommendSwipeCard
entry={nextRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
nextRecommendEntry,
)}
authorSummary={getPublicEntryAuthorSummary(
nextRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
resolvedCoverUrls={resolvedRecommendCoverUrls}
/>
}
/>
</div>
) : null}
</div>
</div>
) : (
<section className="platform-recommend-runtime-panel">
<PlatformEmptyState>
公开广场暂时还没有可展示的作品。
</PlatformEmptyState>
</section>
)}
</div>
);
const mobileDiscoverContent: ReactNode = (
<div
className={`${MOBILE_DISCOVER_PAGE_STAGE_CLASS} platform-mobile-home-stage`}
>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={updateMobileSearchKeyword}
onSubmit={submitMobileSearch}
isSearching={isSearchingPublicCode}
/>
{activeWorkSearchKeyword.trim() ? (
<PlatformWorkSearchResults
keyword={activeWorkSearchKeyword}
entries={workSearchResults}
onOpen={onOpenGalleryDetail}
onClear={clearWorkSearch}
/>
) : (
<>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-category-list-panel">
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : categoryGroups.length > 0 && activeCategoryGroup ? (
<>
<div className="platform-category-filter-row">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied
? activeCategoryFilterLabel
: '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<span className="platform-category-filter-divider" />
<div className="platform-category-chip-scroll scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={group.tag}
type="button"
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button"
>
<span>{activeCategorySortLabel}排序</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{activeCategoryEntries.length > 0 ? (
<div className="platform-category-game-list">
{activeCategoryEntries.map((entry) => (
<PlatformCategoryGameItem
key={`${buildPublicGalleryCardKey(entry)}:mobile-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
categoryTag={activeCategoryGroup.tag}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>
当前筛选下没有作品。
</PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState>
公开广场暂时还没有可分类的作品。
</PlatformEmptyState>
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid min-w-0 gap-3">
{edutainmentFeedEntries.map((entry) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-edutainment`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
feedCardKey={cardKey}
/>
);
})}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<PlatformEmptyState>
暂时还没有可展示的作品。
</PlatformEmptyState>
)}
</section>
) : (
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : discoverFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{discoverFeedEntries.map(
(entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${discoverChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={
mobileCenteredCardKey === cardKey
}
/>
);
},
)}
</div>
) : (
<PlatformEmptyState>
公开广场暂时还没有可展示的作品。
</PlatformEmptyState>
)}
</section>
)}
</>
)}
</div>
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
return (
<button
key={`desktop-${channel.id}`}
type="button"
onClick={() => setDiscoverChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-[1.5rem]"
>
{platformError}
</PlatformStatusMessage>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取作品分类...</PlatformEmptyState>
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied ? activeCategoryFilterLabel : '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={`${group.tag}:desktop-discover-category`}
type="button"
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button shrink-0"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{desktopCategoryGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState>暂时还没有可分类的作品。</PlatformEmptyState>
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : edutainmentFeedEntries.length > 0 ||
onOpenChildMotionDemo ||
onOpenBabyLoveDrawing ? (
<div className="grid gap-4 xl:grid-cols-3">
{edutainmentFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-edutainment`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
{onOpenChildMotionDemo ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenChildMotionDemo}
>
<span className="platform-edutainment-level-card__icon">
<Camera className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{CHILD_MOTION_DEMO_DEFAULT_CARD.title}</strong>
<span>{CHILD_MOTION_DEMO_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{CHILD_MOTION_DEMO_DEFAULT_CARD.summary}
</span>
</button>
) : null}
{onOpenBabyLoveDrawing ? (
<button
type="button"
className="platform-edutainment-level-card"
onClick={onOpenBabyLoveDrawing}
>
<span className="platform-edutainment-level-card__icon">
<Palette className="h-7 w-7" />
</span>
<span className="platform-edutainment-level-card__body">
<strong>{BABY_LOVE_DRAWING_DEFAULT_CARD.title}</strong>
<span>{BABY_LOVE_DRAWING_DEFAULT_CARD.subtitle}</span>
</span>
<span className="platform-edutainment-level-card__summary">
{BABY_LOVE_DRAWING_DEFAULT_CARD.summary}
</span>
</button>
) : null}
</div>
) : (
<PlatformEmptyState>暂时还没有可展示的作品。</PlatformEmptyState>
)}
</section>
) : (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
/>
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取公开作品...</PlatformEmptyState>
) : discoverFeedEntries.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{discoverFeedEntries.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-discover-feed:${discoverChannel}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>
公开广场暂时还没有可展示的作品。
</PlatformEmptyState>
)}
</section>
)}
</div>
);
const categoryContent: ReactNode = isDesktopLayout
? desktopDiscoverContent
: mobileDiscoverContent;
const fallbackCreateStartContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
onClick={onOpenCreateTypePicker}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<PlatformPillBadge
tone="cool"
size="xxs"
className="w-fit tracking-[0.18em]"
>
CREATE
</PlatformPillBadge>
<div className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
开启新的创作
</div>
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
先选择游戏类型,再进入对应的创作工作台继续推进。
</div>
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
<span>选择类型并继续</span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
</div>
</button>
</div>
);
const fallbackDraftContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<section>
<SectionHeader title="我的创作" detail="草稿与已发布" />
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取你的作品...</PlatformEmptyState>
) : myEntries.length > 0 ? (
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
{myEntries.map(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
<CreationLibraryCard
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
entry={entry}
onClick={() => onOpenLibraryDetail(entry)}
onDelete={
onDeleteLibraryEntry
? () => onDeleteLibraryEntry(entry)
: undefined
}
isDeleting={deletingLibraryEntryId === entry.profileId}
/>
),
)}
</div>
) : (
<PlatformEmptyState>
{isAuthenticated
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
: '登录后查看你的作品。'}
</PlatformEmptyState>
)}
</section>
</div>
);
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = (
<>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-2xl"
>
{platformError}
</PlatformStatusMessage>
) : null}
{draftTabContent ?? fallbackDraftContent}
</>
);
const profileContent: ReactNode = (
<div className={`${MOBILE_PROFILE_PAGE_STAGE_CLASS} platform-profile-page`}>
{authUi?.user ? (
<>
<section className="platform-profile-header">
<img
src={profileStillLifeImage}
alt=""
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
onClick={openAvatarPicker}
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 rounded-full"
aria-label="上传头像"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full rounded-full object-cover"
/>
) : (
<img
src={profileMascotImage}
alt=""
className="h-full w-full rounded-full object-cover"
/>
)}
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
<Camera className="h-3.5 w-3.5" />
</span>
</button>
<input
ref={avatarFileInputRef}
type="file"
aria-label="上传头像"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) =>
handleAvatarFileChange(event.target.files?.[0] ?? null)
}
/>
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
{authUi.user.displayName}
</div>
<button
type="button"
onClick={openNicknameModal}
className="platform-profile-edit-button"
aria-label="修改昵称"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
<span>陶泥号: {publicUserCode}</span>
<CopyFeedbackButton
state={profileCopyState}
onClick={copyProfilePublicUserCode}
className="platform-profile-copy-button"
idleLabel="复制"
showIcon={false}
/>
</div>
</div>
</div>
</div>
</section>
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-membership-card"
aria-label="查看权益"
>
<span className="platform-profile-membership-card__badge">
<Crown className="platform-profile-membership-card__crown" />
</span>
<span className="min-w-0 flex-1">
<span className="platform-profile-membership-card__title block text-[16px] font-black leading-tight text-white">
普通用户
</span>
<span className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
升级会员,享专属特权与福利
</span>
</span>
<span className="platform-profile-membership-card__action">
查看权益
</span>
</button>
<section
className="platform-profile-stats-panel"
aria-label="我的数据"
>
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
<ProfileStatCardSkeleton />
</>
) : dashboardError ? (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value="暂不可用"
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游玩"
value="暂不可用"
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏"
value="暂不可用"
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
) : (
<>
<ProfileStatCard
cardKey="wallet"
label="泥点余额"
value={profileDashboardPresentation.walletBalanceLabel}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={profileDashboardPresentation.totalPlayTimeLabel}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={profileDashboardPresentation.playedWorkCountLabel}
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
)}
</div>
</section>
<button
type="button"
onClick={openTaskCenterPanel}
className="platform-profile-daily-task-card"
>
<span className="min-w-0 flex-1">
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
每日任务
</span>
<span className="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
完成任务可领取{' '}
<span className="text-[#c45b2a]">
{profileTaskCardSummary.rewardPoints}
</span>{' '}
泥点
</span>
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
{profileTaskCardSummary.progressCount} /{' '}
{profileTaskCardSummary.threshold}
</span>
<span className="platform-profile-daily-task-card__track">
<span
className="platform-profile-daily-task-card__bar"
style={{
width: `${profileTaskCardSummary.progressPercent}%`,
}}
/>
</span>
</span>
</span>
<img
src={profileMascotImage}
alt=""
className="platform-profile-daily-task-card__mascot"
/>
</button>
<section
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid grid w-full !grid-cols-4">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
icon={Ticket}
imageSrc={profileGiftImage}
onClick={openRewardCodeModal}
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="交流心得"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈与建议"
subLabel="帮我们优化产品"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
/>
</div>
</section>
<section
className="platform-profile-settings-panel"
aria-label="设置入口"
>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={() => authUi.openSettingsModal()}
/>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<PlatformSubpanel as="div" radius="lg" padding="md">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
尚未登录
</div>
<PlatformActionButton
className="mt-4"
onClick={() => authUi?.openLoginModal()}
>
登录
</PlatformActionButton>
</PlatformSubpanel>
</section>
)}
</div>
);
const desktopHomeContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? (
<PlatformStatusMessage
tone="error"
surface="tinted"
size="md"
className="rounded-[1.5rem]"
>
{platformError}
</PlatformStatusMessage>
) : null}
{activeWorkSearchKeyword.trim() ? (
<PlatformWorkSearchResults
keyword={activeWorkSearchKeyword}
entries={workSearchResults}
onOpen={onOpenGalleryDetail}
onClear={clearWorkSearch}
/>
) : (
<>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
>
{desktopHeroCover ? (
desktopHeroEntry ? (
<PlatformWorkCoverArtwork
entry={desktopHeroEntry}
imageSrc={desktopHeroCover}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : (
<ResolvedAssetBackdrop
src={desktopHeroCover}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
)
) : null}
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<PlatformPillBadge
tone="warning"
size="xxs"
className="tracking-[0.18em]"
>
作品
</PlatformPillBadge>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{leadPublicEntry
? describePlatformPublicWorkKind(leadPublicEntry)
: '作品'}
</PlatformPillBadge>
</div>
<div className="max-w-[35rem]">
<div className="text-5xl font-semibold leading-[1.08] text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'挑一个玩家作品,开始今天的游玩。'}
</div>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
<span>查看作品</span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
{desktopHeroStripEntries.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-5">
{desktopHeroStripEntries.map((entry, index) => {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<div
key={`${entry.ownerUserId}:${entry.profileId}:hero-strip`}
className="platform-subpanel overflow-hidden rounded-[1.15rem]"
>
<div className="relative aspect-[1.35/1] overflow-hidden">
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt=""
className="h-full w-full object-cover"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
</div>
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
<span className="text-[var(--platform-text-soft)]">
{`${index + 1}`.padStart(2, '0')}
</span>
<span className="line-clamp-1">{displayName}</span>
</div>
</div>
);
})}
</div>
) : null}
</div>
</button>
<section className="platform-desktop-panel px-5 py-5">
<div className="mb-4 flex items-start justify-between gap-3">
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
TODAY
</PlatformPillBadge>
</div>
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取今日游戏...</PlatformEmptyState>
) : desktopTodayEntries.length > 0 ? (
<div className="space-y-3">
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
<DesktopTrendingItem
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
entry={entry}
rank={index + 1}
onClick={() => openRecommendGalleryDetail(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>今天暂时还没有新游戏。</PlatformEmptyState>
)}
</section>
</div>
<div
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || visibleHistoryEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="推荐" detail="RECOMMENDED" />
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取推荐作品...</PlatformEmptyState>
) : desktopFeaturedGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
{desktopFeaturedGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-featured`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>暂时还没有推荐作品。</PlatformEmptyState>
)}
</section>
{desktopLibraryPreview.length > 0 ||
visibleHistoryEntries.length > 0 ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={
desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'
}
detail="QUICK ACCESS"
/>
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
</div>
{desktopLibraryPreview.length > 0 ? (
<div className="mt-3 space-y-3">
{desktopLibraryPreview.map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
type="button"
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
</div>
</div>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
{entry.visibility === 'published'
? '已发布'
: '草稿'}
</PlatformPillBadge>
</button>
);
})}
</div>
) : (
<div className="mt-3 space-y-3">
{visibleHistoryEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
openRecommendGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
})
}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
作者:{entry.authorDisplayName}
</div>
</div>
<PlatformPillBadge
tone="neutral"
size="xxs"
className="px-3 tracking-[0.18em]"
>
浏览
</PlatformPillBadge>
</button>
);
})}
</div>
)}
</div>
</section>
) : null}
</div>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<PlatformEmptyState>正在读取作品分类...</PlatformEmptyState>
) : activeCategoryGroup && activeCategoryRawCount > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => setIsCategoryFilterPanelOpen(true)}
aria-haspopup="dialog"
className="platform-category-filter-button"
>
<SlidersHorizontal className="h-4 w-4" />
<span>
{categoryFilterApplied
? activeCategoryFilterLabel
: '筛选'}
</span>
<span className="platform-category-filter-button__count">
{activeCategoryFilterCount}
</span>
</button>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => {
hasManualCategoryTagSelectionRef.current = true;
setSelectedCategoryTag(group.tag);
}}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<button
type="button"
onClick={cycleCategorySortMode}
className="platform-category-sort-button shrink-0"
>
<span>{activeCategorySortLabel}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{desktopCategoryGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}:${categoryKindFilter}:${categorySortMode}`}
entry={entry}
onClick={() => openRecommendGalleryDetail(entry)}
className="w-full min-w-0"
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(entry)}
authorSummary={getPublicEntryAuthorSummary(entry)}
/>
))}
</div>
) : (
<PlatformEmptyState>当前筛选下没有作品。</PlatformEmptyState>
)}
</>
) : (
<PlatformEmptyState>暂时还没有可分类的作品。</PlatformEmptyState>
)}
</section>
</>
)}
</div>
);
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent,
category: categoryContent,
create: createContent,
saves: savesContent,
profile: profileContent,
} satisfies Record<PlatformHomeTab, ReactNode>;
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
visibleTabs.includes(tab),
).map((tab) => {
const shouldMountPanel = tab === activeTab || visitedTabs.has(tab);
return (
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
{shouldMountPanel ? tabContentById[tab] : null}
</PlatformTabPanel>
);
});
const profileEditModals: ReactNode = (
<>
{isNicknameModalOpen ? (
<ProfileNicknameModal
value={nicknameInput}
error={nicknameError}
isSaving={isSavingNickname}
onChange={(value) => {
setNicknameInput(value);
setNicknameError(null);
}}
onClose={() => setIsNicknameModalOpen(false)}
onSubmit={submitNickname}
/>
) : null}
{avatarSource && avatarImageSize ? (
<SquareImageCropModal
source={avatarSource}
imageSize={avatarImageSize}
cropRect={avatarCrop}
titleId="profile-avatar-crop-title"
labels={{
title: '裁剪头像',
close: '关闭头像裁剪',
editor: '头像裁剪操作区',
previewAlt: '头像裁剪预览',
cancel: '取消',
submit: '上传',
saving: '上传中',
}}
error={avatarError}
isSaving={isSavingAvatar}
onCropRectChange={updateAvatarCrop}
onClose={() => {
setAvatarSource(null);
setAvatarImageSize(null);
setAvatarError(null);
}}
onSubmit={submitAvatar}
/>
) : null}
{avatarError && !avatarSource ? (
<div className="pointer-events-none fixed left-1/2 top-5 z-[90] w-[min(92vw,22rem)] -translate-x-1/2 rounded-2xl border border-rose-400/25 bg-white px-4 py-3 text-center text-sm font-semibold text-rose-600 shadow-2xl">
{avatarError}
</div>
) : null}
</>
);
const rewardCodeModal: ReactNode = isRewardCodeOpen ? (
<RewardCodeRedeemModal
value={rewardCodeInput}
isSubmitting={isSubmittingRewardCode}
error={rewardCodeError}
success={rewardCodeSuccess}
onChange={setRewardCodeInput}
onSubmit={submitRewardCode}
onClose={() => setIsRewardCodeOpen(false)}
/>
) : null;
const rechargeModal: ReactNode = isRechargeOpen ? (
<ProfileRechargeModal
center={rechargeCenter}
isLoading={isLoadingRechargeCenter}
error={rechargeError}
submittingProductId={submittingRechargeProductId}
nativePayment={nativeWechatPayment}
activeTab={activeRechargeTab}
onTabChange={setActiveRechargeTab}
onClose={() => setIsRechargeOpen(false)}
onRetry={loadRechargeCenter}
onBuy={buyRechargeProduct}
onConfirmNativePayment={confirmNativeWechatPayment}
/>
) : null;
const rechargePaymentResultModal: ReactNode = rechargePaymentResult ? (
<RechargePaymentResultModal
result={rechargePaymentResult}
onClose={() => setRechargePaymentResult(null)}
/>
) : null;
const rechargePaymentConfirmationMask: ReactNode =
wechatRechargeOrderConfirmationState ? (
<RechargePaymentConfirmationMask
orderId={wechatRechargeOrderConfirmationState.orderId}
/>
) : null;
const isRechargePaymentConfirmationPending = Boolean(
wechatRechargeOrderConfirmationState,
);
const categoryFilterDialog: ReactNode = isCategoryFilterPanelOpen ? (
<PlatformCategoryFilterDialog
kindFilter={categoryKindFilter}
sortMode={categorySortMode}
resultCount={activeCategoryFilterCount}
onKindFilterChange={setCategoryKindFilter}
onSortModeChange={setCategorySortMode}
onReset={() => {
setCategoryKindFilter('all');
setCategorySortMode('composite');
}}
onClose={() => setIsCategoryFilterPanelOpen(false)}
/>
) : null;
const qrScannerModal: ReactNode = isQrScannerOpen ? (
<ProfileQrScannerModal
error={qrScannerError}
result={qrScannerResult}
onClose={() => {
setIsQrScannerOpen(false);
setQrScannerError(null);
setQrScannerResult(null);
}}
onError={setQrScannerError}
onResult={(value) => {
setQrScannerError(null);
setQrScannerResult(value);
}}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
return (
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className={`platform-mobile-entry-shell ${isMobileRecommendTab ? 'platform-mobile-entry-shell--recommend' : ''} flex h-full min-h-0 min-w-0 flex-col overflow-hidden`}
>
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{isAuthenticated && activeTab === 'profile' ? (
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={openQrScannerPanel}
className="platform-profile-header__icon-button"
aria-label="扫码"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi?.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
) : isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
>
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>
{profileDashboardPresentation.walletBalanceWithUnitLabel}
</span>
</button>
) : !isAuthenticated ? (
<PlatformActionButton
size="xs"
className="shrink-0"
onClick={openUserSurface}
>
<LogIn className="h-3.5 w-3.5" />
登录
</PlatformActionButton>
) : null}
</div>
) : null}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div className="platform-mobile-bottom-dock min-w-0 shrink-0">
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : visibleTabs.length === 4 ? 'grid-cols-4' : visibleTabs.length === 3 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
key={tab}
active={activeTab === tab}
label={
activeTab === 'home' && tab === 'home'
? '下一个'
: tabLabels[tab]
}
icon={
activeTab === 'home' && tab === 'home'
? ChevronDown
: tabIcons[tab]
}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
if (activeTab === 'home' && tab === 'home') {
selectNextRecommendEntry();
return;
}
onTabChange(tab);
}}
/>
))}
</div>
</div>
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
isLoading={isLoadingReferral}
isSubmittingRedeem={isSubmittingReferralRedeem}
redeemCode={referralRedeemCode}
copyInviteState={inviteCopyState}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onCopyInvite={copyInviteInfo}
onRedeemCodeChange={setReferralRedeemCode}
onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{qrScannerModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={authUi?.platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
{profileEditModals}
</div>
{rechargePaymentConfirmationMask}
</>
);
}
return (
<>
<div
inert={isRechargePaymentConfirmationPending ? true : undefined}
className="flex h-full min-h-0 flex-col"
>
<div className="flex h-full min-h-0 flex-col">
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<RpgEntryBrandLogo className="shrink-0" decorative />
<PublicCodeSearchBar
value={desktopSearchKeyword}
onChange={updateDesktopSearchKeyword}
onSubmit={submitDesktopSearch}
isSearching={
!onSearchPublicCode || Boolean(isSearchingPublicCode)
}
className="max-w-[34rem] flex-1"
/>
</div>
<div className="flex items-center gap-3">
{isAuthenticated &&
(activeTab === 'create' || activeTab === 'saves') ? (
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={
profileDashboardPresentation.walletBalanceWithUnitLabel
}
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>
{profileDashboardPresentation.walletBalanceWithUnitLabel}
</span>
</button>
) : null}
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span
className="flex h-11 w-11 items-center justify-center overflow-hidden rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
avatarLabel
)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '登录'}
</span>
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '账号入口'}
</span>
</span>
</button>
</div>
</div>
<div className="mt-5 flex min-h-0 gap-5">
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
{visibleTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
showDot={tab === 'saves' && hasUnreadDraftUpdate}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
if (isRechargePaymentConfirmationPending) {
return;
}
onTabChange(tab);
}}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
</div>
</div>
{rewardCodeModal}
{rechargeModal}
{rechargePaymentResultModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
isLoading={isLoadingReferral}
isSubmittingRedeem={isSubmittingReferralRedeem}
redeemCode={referralRedeemCode}
copyInviteState={inviteCopyState}
error={referralError}
success={referralSuccess}
onClose={() => setProfilePopupPanel(null)}
onCopyInvite={copyInviteInfo}
onRedeemCodeChange={setReferralRedeemCode}
onSubmitRedeemCode={submitReferralRedeemCode}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
isLoading={isProfilePlayStatsLoading}
error={profilePlayStatsError}
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={onCloseProfilePlayStats ?? (() => undefined)}
onOpenWork={onOpenPlayedWork}
onResumeSave={onResumeSave}
/>
) : null}
{isWalletLedgerOpen ? (
<WalletLedgerModal
ledger={walletLedger}
fallbackBalance={remainingNarrativeCoins}
isLoading={isLoadingWalletLedger}
error={walletLedgerError}
onClose={() => setIsWalletLedgerOpen(false)}
onRetry={loadWalletLedger}
/>
) : null}
<LegalDocumentModal
document={activeLegalDocument}
open={Boolean(activeLegalDocument)}
platformTheme={authUi?.platformTheme}
onClose={() => setActiveLegalDocumentId(null)}
/>
{profileEditModals}
{rechargePaymentConfirmationMask}
</>
);
}
export const PlatformHomeView = RpgEntryHomeView;