Files
Genarrative/src/components/rpg-entry/RpgEntryHomeView.tsx

7134 lines
242 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,
LogIn,
MessageCircle,
Palette,
Pencil,
Plus,
ScanLine,
Search,
Settings,
Share2,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import {
type ComponentType,
type CSSProperties,
type PointerEvent,
type ReactNode,
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 profileInviteImage from '../../../media/profile/_Image (5).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,
ProfileTaskItem,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams,
WechatMiniProgramVirtualPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
updateAuthProfile,
} from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
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,
} from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { LegalDocumentModal } from '../common/LegalDocumentModal';
import {
getLegalDocument,
ICP_RECORD_NUMBER,
ICP_RECORD_URL,
LEGAL_DOCUMENTS,
type LegalDocumentId,
} from '../common/legalDocuments';
import {
buildCenteredSquareImageCropRect,
clampSquareImageCropRect,
SquareImageCropModal,
type SquareImageCropRect,
} from '../common/SquareImageCropModal';
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 {
buildPlatformWorldDisplayTags,
describePlatformThemeLabel,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
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;
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
onLikeRecommendEntry?: (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>;
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 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_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
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_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
left.index - right.index,
)
.slice(0, 1)
.map(({ task }) => task);
}
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;
};
type RechargePaymentResultKind = 'success' | 'pending' | 'cancel' | 'failed';
type RechargePaymentResult = {
kind: RechargePaymentResultKind;
title: string;
message: string;
};
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';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
type PlatformCategoryKindFilter =
| 'all'
| 'puzzle'
| 'match3d'
| 'square-hole'
| 'visual-novel'
| 'bark-battle'
| 'big-fish'
| 'custom-world';
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
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 PLATFORM_CATEGORY_KIND_FILTERS: Array<{
id: PlatformCategoryKindFilter;
label: string;
}> = [
{ id: 'all', label: '全部' },
{ id: 'puzzle', label: '拼图' },
{ id: 'match3d', label: '抓鹅' },
{ id: 'square-hole', label: '方洞' },
{ id: 'visual-novel', label: '视觉' },
{ id: 'bark-battle', label: '汪汪' },
{ id: 'big-fish', label: '大鱼' },
{ id: 'custom-world', label: 'RPG' },
];
const PLATFORM_CATEGORY_SORT_OPTIONS: Array<{
id: PlatformCategorySortMode;
label: string;
}> = [
{ id: 'composite', label: '综合' },
{ id: 'latest', label: '最新' },
{ id: 'play', label: '游玩' },
{ id: 'like', label: '点赞' },
];
const BABY_LOVE_DRAWING_DEFAULT_CARD = {
title: '宝贝爱画',
subtitle: '空白画板',
summary: '挥动小手画一张画。',
};
const CHILD_MOTION_DEMO_DEFAULT_CARD = {
title: '热身关卡',
subtitle: '动作识别热身',
summary: '站位、招手和左右手活动。',
};
const PLATFORM_RANKING_TABS: Array<{
id: PlatformRankingTab;
label: string;
metricLabel: string;
emptyText: string;
}> = [
{
id: 'hot',
label: '热门榜',
metricLabel: '游玩',
emptyText: '公开广场暂时还没有热门作品。',
},
{
id: 'remix',
label: '改造榜',
metricLabel: '改造',
emptyText: '公开广场暂时还没有改造作品。',
},
{
id: 'new',
label: '新品榜',
metricLabel: '近7日',
emptyText: '近 7 日暂时还没有新品。',
},
{
id: 'like',
label: '点赞榜',
metricLabel: '点赞',
emptyText: '公开广场暂时还没有点赞作品。',
},
];
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 EmptyShelf({ text }: { text: string }) {
return (
<div
className={`${PANEL_SURFACE_CLASS} min-w-0 rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
>
{text}
</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,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
variant = 'standard',
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
authorAvatarUrl?: string | 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 = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const cardLabel = `${entry.worldName}${typeLabel}${formatCompactCount(playCount)}游玩,${formatCompactCount(remixCount)}改造,${formatCompactCount(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>{formatCompactCount(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) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral max-w-full px-2.5"
>
<span className="truncate">{tag}</span>
</span>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
</span>
)}
</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 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={isDeleting}
className="platform-button platform-button--danger absolute right-2 top-2 z-20 min-h-0 rounded-full px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
>
{isDeleting ? '删除中' : '删除'}
</button>
) : 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">
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-semibold tracking-[0.12em] ${
entry.visibility === 'published'
? 'border-emerald-300/25 bg-emerald-500/12 text-emerald-50'
: 'border-amber-300/25 bg-amber-500/12 text-amber-50'
}`}
>
{statusLabel}
</span>
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-[var(--platform-text-base)]">
<span className="truncate">{metaLabel}</span>
</span>
</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">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
<span className="truncate">{primaryTag}</span>
</span>
<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,
}: {
entry: PlatformPublicGalleryCard;
position: 'previous' | 'next';
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
return (
<div
className="platform-recommend-runtime-preview"
aria-hidden="true"
data-preview-position={position}
>
{coverImage ? (
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
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 RecommendSwipeCard({
entry,
authorAvatarUrl,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
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}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(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={`点赞 ${formatCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatCompactCount(likeCount)} 个赞`}
>
{formatCompactCount(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={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
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={`改造 ${formatCompactCount(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">
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-[var(--platform-text-base)]">
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</span>
</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,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<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,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
showDot?: boolean;
}) {
const ariaLabel = showDot ? `${label},有新草稿` : label;
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
>
<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">
{describePublicGalleryCardKind(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) => (
<span
key={`${entry.profileId}-trend-tag-${index}-${tag}`}
className="platform-pill platform-pill--neutral px-2.5"
>
{tag}
</span>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePublicGalleryCardKind(entry)}
</span>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
function PlatformRankingItem({
entry,
rank,
metricLabel,
metricValue,
onClick,
}: {
entry: PlatformPublicGalleryCard;
rank: number;
metricLabel: string;
metricValue: number;
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)]">
{formatCompactCount(metricValue)}
</span>
<span>{metricLabel}</span>
<span>·</span>
<span>{describePublicGalleryCardKind(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 = [
describePublicGalleryCardKind(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>{formatCompactCount(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>
<button
type="button"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
aria-label="关闭"
>
<XCircle className="h-5 w-5" />
</button>
</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 buildPublicCategoryGroups(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const publicEntryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
publicEntryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
const categoryMap = new Map<string, PlatformPublicGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldDisplayTags(entry, 3);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
function getPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
filterGeneralPublicWorks([...featuredEntries, ...latestEntries]).forEach(
(entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
},
);
return Array.from(entryMap.values());
}
function getAllPlatformPublicEntries(
featuredEntries: PlatformPublicGalleryCard[],
latestEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}
function normalizePlatformSearchText(value: string | null | undefined) {
return (value ?? '').trim().toLocaleLowerCase('zh-CN');
}
function normalizePlatformCompactSearchText(value: string | null | undefined) {
return normalizePlatformSearchText(value).replace(/[\s_-]+/gu, '');
}
function getPlatformSearchableWorkIds(entry: PlatformPublicGalleryCard) {
const ids = [entry.publicWorkCode, entry.profileId];
if ('workId' in entry) {
ids.push(entry.workId);
}
return ids.filter((value): value is string => Boolean(value?.trim()));
}
function buildPlatformWorkSearchText(entry: PlatformPublicGalleryCard) {
return [
...getPlatformSearchableWorkIds(entry),
entry.worldName,
entry.authorDisplayName,
entry.summaryText,
entry.subtitle,
].join(' ');
}
function matchesPlatformWorkSearch(
entry: PlatformPublicGalleryCard,
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
const compactKeyword = normalizePlatformCompactSearchText(keyword);
if (!normalizedKeyword) {
return false;
}
const normalizedSearchText = normalizePlatformSearchText(
buildPlatformWorkSearchText(entry),
);
if (normalizedSearchText.includes(normalizedKeyword)) {
return true;
}
return (
Boolean(compactKeyword) &&
normalizePlatformCompactSearchText(
buildPlatformWorkSearchText(entry),
).includes(compactKeyword)
);
}
function filterPlatformWorkSearchResults(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
return entries
.filter((entry) => matchesPlatformWorkSearch(entry, keyword))
.sort((left, right) => {
const leftCode = getPlatformSearchableWorkIds(left)[0] ?? '';
const rightCode = getPlatformSearchableWorkIds(right)[0] ?? '';
const normalizedKeyword = normalizePlatformSearchText(keyword);
const leftNameStarts = normalizePlatformSearchText(
left.worldName,
).startsWith(normalizedKeyword);
const rightNameStarts = normalizePlatformSearchText(
right.worldName,
).startsWith(normalizedKeyword);
if (leftNameStarts !== rightNameStarts) {
return leftNameStarts ? -1 : 1;
}
const leftCodeStarts = normalizePlatformCompactSearchText(
leftCode,
).startsWith(normalizePlatformCompactSearchText(keyword));
const rightCodeStarts = normalizePlatformCompactSearchText(
rightCode,
).startsWith(normalizePlatformCompactSearchText(keyword));
if (leftCodeStarts !== rightCodeStarts) {
return leftCodeStarts ? -1 : 1;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function isExactPublicWorkCodeSearch(
entries: PlatformPublicGalleryCard[],
keyword: string,
) {
const normalizedKeyword = normalizePlatformSearchText(keyword);
return entries.some(
(entry) =>
Boolean(entry.publicWorkCode?.trim()) &&
normalizePlatformSearchText(entry.publicWorkCode) === normalizedKeyword,
);
}
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
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} 个作品`} />
<button
type="button"
onClick={onClear}
className="platform-icon-button"
aria-label="清空搜索"
title="清空搜索"
>
×
</button>
</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>
<span className="platform-pill platform-pill--neutral shrink-0 px-3">
{describePublicGalleryCardKind(entry)}
</span>
</button>
);
})}
</div>
) : (
<EmptyShelf text="没有匹配的公开作品。" />
)}
</section>
);
}
function buildPublicWorkAuthorLookupKey(entry: PlatformPublicGalleryCard) {
if ('authorPublicUserCode' in entry) {
const authorPublicUserCode = entry.authorPublicUserCode?.trim();
if (authorPublicUserCode) {
return `code:${authorPublicUserCode}`;
}
}
const ownerUserId = entry.ownerUserId.trim();
return ownerUserId ? `id:${ownerUserId}` : null;
}
async function getPublicWorkAuthorSummary(
authorLookupKey: string,
): Promise<PublicUserSummary | null> {
if (authorLookupKey.startsWith('code:')) {
return getPublicAuthUserByCode(authorLookupKey.slice('code:'.length));
}
if (authorLookupKey.startsWith('id:')) {
return getPublicAuthUserById(authorLookupKey.slice('id:'.length));
}
return null;
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
if (isBigFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isSquareHoleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isJumpHopGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('???');
}
if (isWoodenFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('???');
}
if (isVisualNovelGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isBarkBattleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('??');
}
if (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
function getPlatformWorldPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('playCount' in entry && entry.playCount) || 0),
);
}
function getPlatformWorldRemixCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('remixCount' in entry && entry.remixCount) || 0),
);
}
function getPlatformWorldRecentPlayCount(entry: PlatformWorldCardLike) {
return Math.max(
0,
Math.round(('recentPlayCount7d' in entry && entry.recentPlayCount7d) || 0),
);
}
function getPlatformWorldTimestamp(entry: PlatformWorldCardLike) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
return parsePlatformEntryTimestamp(rawTime);
}
// 首页“今日游戏”只看作品首次发布时间,按玩家浏览器本地自然日判断。
function parsePlatformEntryTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
return Number.isNaN(timestampMs) ? 0 : timestampMs;
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function isSameLocalCalendarDay(left: Date, right: Date) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function isPublishedToday(entry: PlatformPublicGalleryCard, now = new Date()) {
const publishedAtTimestamp = parsePlatformEntryTimestamp(entry.publishedAt);
if (publishedAtTimestamp <= 0) {
return false;
}
const publishedAt = new Date(publishedAtTimestamp);
return isSameLocalCalendarDay(publishedAt, now);
}
function filterTodayPublishedEntries(entries: PlatformPublicGalleryCard[]) {
const now = new Date();
return entries.filter((entry) => isPublishedToday(entry, now));
}
function sortEntriesByMetric(
entries: PlatformPublicGalleryCard[],
getMetric: (entry: PlatformPublicGalleryCard) => number,
) {
return [...entries].sort((left, right) => {
const metricDiff = getMetric(right) - getMetric(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function buildPlatformRankingEntries(
entries: PlatformPublicGalleryCard[],
tab: PlatformRankingTab,
) {
if (tab === 'hot') {
return sortEntriesByMetric(entries, getPlatformWorldPlayCount);
}
if (tab === 'remix') {
return sortEntriesByMetric(entries, getPlatformWorldRemixCount);
}
if (tab === 'like') {
return sortEntriesByMetric(entries, getPlatformWorldLikeCount);
}
return sortEntriesByMetric(entries, getPlatformWorldRecentPlayCount);
}
function getPlatformRankingMetricValue(
entry: PlatformPublicGalleryCard,
tab: PlatformRankingTab,
) {
if (tab === 'remix') {
return getPlatformWorldRemixCount(entry);
}
if (tab === 'like') {
return getPlatformWorldLikeCount(entry);
}
if (tab === 'new') {
return getPlatformWorldRecentPlayCount(entry);
}
return getPlatformWorldPlayCount(entry);
}
function getPlatformCategoryCompositeScore(entry: PlatformPublicGalleryCard) {
// 分类频道只使用公开读模型已经返回的指标做前端排序,不在 UI 层伪造评分数据。
return (
getPlatformWorldPlayCount(entry) +
getPlatformWorldRemixCount(entry) +
getPlatformWorldLikeCount(entry) +
getPlatformWorldRecentPlayCount(entry)
);
}
function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
return 'custom-world';
}
function matchesPlatformCategoryKindFilter(
entry: PlatformPublicGalleryCard,
kindFilter: PlatformCategoryKindFilter,
) {
return (
kindFilter === 'all' || getPlatformCategoryKindFilter(entry) === kindFilter
);
}
function sortPlatformCategoryEntries(
entries: PlatformPublicGalleryCard[],
sortMode: PlatformCategorySortMode,
) {
return [...entries].sort((left, right) => {
if (sortMode === 'latest') {
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
}
const metricDiff =
sortMode === 'play'
? getPlatformWorldPlayCount(right) - getPlatformWorldPlayCount(left)
: sortMode === 'like'
? getPlatformWorldLikeCount(right) - getPlatformWorldLikeCount(left)
: getPlatformCategoryCompositeScore(right) -
getPlatformCategoryCompositeScore(left);
if (metricDiff !== 0) {
return metricDiff;
}
return getPlatformWorldTimestamp(right) - getPlatformWorldTimestamp(left);
});
}
function getPlatformCategoryPrimaryMetric(entry: PlatformPublicGalleryCard) {
const likeCount = getPlatformWorldLikeCount(entry);
if (likeCount > 0) {
return { label: '点赞', value: likeCount };
}
const recentPlayCount = getPlatformWorldRecentPlayCount(entry);
if (recentPlayCount > 0) {
return { label: '近7日', value: recentPlayCount };
}
return { label: '游玩', value: getPlatformWorldPlayCount(entry) };
}
function formatCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return `${normalizedValue}`;
}
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 formatCompactPlayTime(playTimeMs: number) {
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
const days = totalMinutes / 1440;
if (days >= 10) {
return `${Math.floor(days)}`;
}
if (days >= 1) {
return `${days.toFixed(days >= 3 ? 0 : 1)}`;
}
const hours = totalMinutes / 60;
if (hours >= 1) {
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
}
return `${Math.max(0, totalMinutes)}`;
}
// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。
function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
return `${roundedHours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
function formatDashboardCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
function isWithinProfileInviteRedeemWindow(
createdAt: string | null | undefined,
) {
if (!createdAt) {
return false;
}
const createdTime = new Date(createdAt).getTime();
if (Number.isNaN(createdTime)) {
return false;
}
return Date.now() - createdTime <= PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS;
}
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 formatPlayedWorkType(value: string | null | undefined) {
const normalizedValue = (value ?? '').toLowerCase();
if (normalizedValue === 'puzzle') {
return '拼图';
}
if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') {
return '抓鹅';
}
if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') {
return '方洞';
}
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
return '大鱼';
}
return 'RPG';
}
function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
function buildPublicUserCode(user: AuthUser | null | undefined) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
const raw =
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() ||
user?.username.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 || user?.username || '叙')
.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.75rem] items-center justify-center gap-2 px-3 py-3 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-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[12px] 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-[5.25rem] flex-col items-center justify-center gap-2 px-2.5 py-3 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-[13px] 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-[11px] 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-4 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-5 w-5" />
</span>
<span className="truncate text-[15px] 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 ProfileSecondaryShortcutButton({
label,
subLabel,
icon,
onClick,
}: {
label: string;
subLabel?: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
>
<span className="platform-profile-secondary-shortcut__icon">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
{subLabel ? (
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</span>
) : null}
</span>
</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>
<button
type="button"
aria-label="关闭昵称修改"
onClick={onClose}
className="platform-profile-icon-button flex h-8 w-8 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="px-5 py-5">
<label className="block">
<span className="sr-only"></span>
<input
autoFocus
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit();
}
}}
maxLength={20}
className="w-full rounded-2xl border border-white/12 bg-white/10 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-[var(--platform-surface-hover-border)]"
placeholder="输入新昵称"
/>
</label>
{error ? (
<div className="mt-3 rounded-2xl border border-rose-400/25 bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
{error}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
onClick={onSubmit}
disabled={isSaving}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? '保存中' : '保存'}
</button>
</div>
</div>
</div>
</div>
);
}
const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
new_user_registration_reward: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
daily_task_reward: '每日任务奖励',
};
function formatWalletLedgerAmount(amountDelta: number) {
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
}
function formatRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
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 = ''] = result.split(':');
const orderId = requestId
.replace(/^wechat_pay_/, '')
.replace(/_\d+$/, '')
.trim();
const status =
rawStatus === 'success'
? 'success'
: rawStatus === 'cancel'
? 'cancel'
: 'fail';
return {
requestId,
orderId: orderId || null,
status,
};
}
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 === 'paid') {
return latestResponse;
}
for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) {
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
if (latestResponse.order.status === 'paid') {
return latestResponse;
}
}
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 effectiveBonusPoints = product.bonusPoints;
const badgeLabel = product.badgeLabel;
const value =
product.kind === 'points'
? `${product.pointsAmount}${effectiveBonusPoints > 0 ? `+${effectiveBonusPoints}` : ''}泥点`
: `${product.durationDays}`;
return (
<button
type="button"
onClick={() => onBuy(product)}
disabled={Boolean(submittingProductId)}
className="platform-subpanel platform-interactive-card relative min-h-[7.25rem] rounded-[1.15rem] px-3.5 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
{badgeLabel ? (
<span className="platform-pill platform-pill--warm absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 text-[10px]">
{badgeLabel}
</span>
) : 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>
</button>
);
}
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 =
center?.membership.status === 'active'
? center.membership.expiresAt
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
: '会员已生效'
: '普通用户';
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>
<button
type="button"
aria-label="关闭账户充值"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="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 ? (
<div className="platform-profile-error mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
>
</button>
</div>
) : 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>
) : (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
)}
{nativePayment ? (
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-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>
<button
type="button"
onClick={onConfirmNativePayment}
disabled={nativePayment.isConfirming}
className="platform-primary-button mt-4 rounded-2xl px-4 py-2 text-xs font-black disabled:cursor-wait disabled:opacity-60"
>
{nativePayment.isConfirming ? '确认中' : '我已支付'}
</button>
</div>
) : 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>
<button
type="button"
onClick={onClose}
className="platform-primary-button mt-5 w-full rounded-2xl px-4 py-3 text-sm font-black"
>
</button>
</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 entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[30rem] overflow-hidden rounded-[1.35rem] bg-[linear-gradient(180deg,#fff7f8_0%,#ffffff_38%,#f8fafc_100%)] text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/78 text-[#ff4056] shadow-sm"
aria-label="关闭泥点账单"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
LEDGER
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-white/70 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Coins className="h-3.5 w-3.5 text-[#ff4056]" />
<span>{balance}</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full bg-[#ff4056] px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
) : isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className="h-16 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : entries.length === 0 ? (
<div className="mt-5 rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm font-semibold text-zinc-500">
</div>
) : (
<div className="mt-5 space-y-2.5">
{entries.map((entry) => {
const isIncome = entry.amountDelta > 0;
const label =
WALLET_LEDGER_SOURCE_LABELS[entry.sourceType] ??
entry.sourceType;
return (
<div
key={entry.id}
className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-zinc-900">
{label}
</div>
<div className="mt-1 text-xs font-semibold text-zinc-500">
{formatPlatformWorldTime(entry.createdAt)}
</div>
</div>
<div className="shrink-0 text-right">
<div
className={`text-base font-black ${
isIncome ? 'text-emerald-600' : 'text-rose-500'
}`}
>
{formatWalletLedgerAmount(entry.amountDelta)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
{entry.balanceAfter}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
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>
<button
type="button"
aria-label="关闭每日任务"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
{error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
>
</button>
</div>
) : null}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : 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 ? (
<div className="platform-subpanel rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`;
return (
<div
key={task.taskId}
className="platform-subpanel rounded-2xl px-4 py-4"
>
<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)]">
{PROFILE_TASK_STATUS_LABELS[task.status]}
</div>
</div>
</div>
<button
type="button"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isClaiming
? '领取中'
: task.status === 'claimed'
? '已领取'
: isClaimable
? '领取'
: '未完成'}
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function RewardCodeRedeemModal({
value,
isSubmitting,
error,
success,
onChange,
onSubmit,
onClose,
}: {
value: string;
isSubmitting: boolean;
error: string | null;
success: string | null;
onChange: (value: string) => void;
onSubmit: () => void;
onClose: () => void;
}) {
return (
<div className="platform-modal-backdrop fixed inset-0 z-[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>
<button
type="button"
aria-label="关闭兑换码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<input
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
onSubmit();
}
}}
className="platform-profile-input w-full rounded-2xl px-4 py-3 text-sm font-semibold uppercase tracking-normal"
placeholder="输入兑换码"
autoFocus
/>
<button
type="button"
onClick={onSubmit}
disabled={isSubmitting || !value.trim()}
className="platform-primary-button w-full rounded-2xl px-4 py-3 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? '兑换中' : '兑换'}
</button>
{error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
{error}
</div>
) : null}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : 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>
<button
type="button"
aria-label="关闭扫码"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
<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 ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{result}
</div>
) : error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
{error}
</div>
) : null}
</div>
</div>
</div>
);
}
function ProfileReferralModal({
panel,
center,
isLoading,
isSubmittingRedeem,
redeemCode,
error,
success,
onClose,
onCopyInvite,
onRedeemCodeChange,
onSubmitRedeemCode,
}: {
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
redeemCode: string;
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">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-2 z-10 flex h-8 w-8 items-center justify-center rounded-full text-[#ff4056]"
aria-label={`关闭${title}`}
>
×
</button>
<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) => (
<div
key={qrCode.label}
className="rounded-xl border border-zinc-200 bg-zinc-50 p-2.5 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>
</div>
))}
</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 ? (
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-600">
</div>
) : (
<form
className="mt-5 space-y-3"
onSubmit={(event) => {
event.preventDefault();
onSubmitRedeemCode();
}}
>
<input
value={redeemCode}
onChange={(event) => onRedeemCodeChange(event.target.value)}
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black uppercase tracking-[0.16em] text-zinc-950 outline-none focus:border-[#ff4056]"
placeholder="邀请码"
aria-label="邀请码"
autoComplete="off"
autoFocus
/>
<button
type="submit"
disabled={isSubmittingRedeem || !normalizedRedeemCode}
className="flex w-full items-center justify-center rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmittingRedeem ? '提交中' : '提交'}
</button>
</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">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500">
</div>
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
{center?.inviteCode ?? '--------'}
</div>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3.5 py-3 text-sm font-semibold leading-6 text-amber-900">
<div>
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
</div>
<div></div>
</div>
<button
type="button"
onClick={onCopyInvite}
disabled={!center?.inviteCode}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
<Copy className="h-4 w-4" />
</button>
<div className="rounded-xl bg-zinc-50 px-3.5 py-3">
<div className="text-xs font-black text-zinc-900"></div>
{center?.invitedUsers?.length ? (
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
{center.invitedUsers.map((user) => (
<div
key={`${user.userId}-${user.boundAt}`}
className="flex items-center gap-3 rounded-lg bg-white px-2.5 py-2"
>
<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>
</div>
))}
</div>
) : (
<div className="mt-3 rounded-lg bg-white px-3 py-3 text-center text-xs font-semibold text-zinc-500">
</div>
)}
</div>
</div>
)}
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{success ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{success}
</div>
) : 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">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭存档"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
SAVES
</div>
<div className="mt-1 text-2xl font-black"></div>
</div>
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : 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>
) : (
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-500">
</div>
)}
</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">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭玩过"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
{formatTotalPlayTimeHours(stats?.totalPlayTimeMs ?? 0)}
</span>
</div>
</div>
{error ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : null}
{isLoading ? (
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-xl bg-zinc-100"
/>
))}
</div>
) : hasArchiveEntries || hasPlayedWorks ? (
<div className="mt-5 space-y-5">
{hasArchiveEntries ? (
<section>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<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>
<div className="mb-2 text-xs font-black text-zinc-500">
</div>
<div className="space-y-3">
{playedWorks.map((work) => (
<button
type="button"
key={`${work.worldKey}:${work.lastPlayedAt}`}
onClick={() => onOpenWork?.(work)}
className="w-full rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-left transition hover:border-[#ff4056] hover:bg-white"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="line-clamp-1 text-base font-black text-zinc-950">
{work.worldTitle}
</div>
{work.worldSubtitle ? (
<div className="mt-1 line-clamp-1 text-xs text-zinc-500">
{work.worldSubtitle}
</div>
) : null}
</div>
<span className="shrink-0 rounded-full bg-rose-50 px-2.5 py-1 text-[11px] font-bold text-[#ff4056]">
{formatPlayedWorkType(work.worldType)}
</span>
</div>
<div className="mt-3 grid gap-2 text-xs text-zinc-500 sm:grid-cols-3">
<span className="truncate">
{formatPlayedWorkId(work)}
</span>
<span className="truncate">
{formatSnapshotTime(work.lastPlayedAt)}
</span>
<span className="truncate">
{' '}
{formatCompactPlayTime(work.lastObservedPlayTimeMs)}
</span>
</div>
</button>
))}
</div>
</section>
) : null}
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
</div>
)}
</div>
</div>
</div>
);
}
export function RpgEntryHomeView({
activeTab,
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,
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
onSearchPublicCode,
isSearchingPublicCode = false,
onOpenProfileDashboardCard,
profilePlayStats = null,
isProfilePlayStatsOpen = false,
isProfilePlayStatsLoading = false,
profilePlayStatsError = null,
onCloseProfilePlayStats,
onOpenPlayedWork,
onOpenFeedback,
onRechargeSuccess,
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 [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 [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 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 [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [categoryKindFilter, setCategoryKindFilter] =
useState<PlatformCategoryKindFilter>('all');
const [categorySortMode, setCategorySortMode] =
useState<PlatformCategorySortMode>('composite');
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>('hot');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
const [profileCopyState, setProfileCopyState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const [activeLegalDocumentId, setActiveLegalDocumentId] =
useState<LegalDocumentId | null>(null);
const profileCopyResetTimerRef = useRef<number | null>(null);
const avatarFileInputRef = useRef<HTMLInputElement | null>(null);
const pendingWechatRechargeOrderIdRef = 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 authorLookupKey = buildPublicWorkAuthorLookupKey(entry);
if (!authorLookupKey) {
return null;
}
return (
publicAuthorSummariesByKey[authorLookupKey]?.avatarUrl?.trim() || 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 =
PLATFORM_CATEGORY_KIND_FILTERS.find(
(option) => option.id === categoryKindFilter,
)?.label ?? '全部';
const activeCategorySortLabel =
PLATFORM_CATEGORY_SORT_OPTIONS.find(
(option) => option.id === categorySortMode,
)?.label ?? '综合';
const activeCategoryFilterCount = activeCategoryEntries.length;
const categoryFilterApplied = categoryKindFilter !== 'all';
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 remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
const totalPlayTime = formatTotalPlayTimeHours(
profileDashboard?.totalPlayTimeMs ?? 0,
);
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const canShowReferralRedeemShortcut =
isAuthenticated &&
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
isReferralCenterInitialized &&
Boolean(referralCenter) &&
referralCenter?.hasRedeemedCode !== true;
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
> = isAuthenticated
? {
home: Sparkles,
category: Compass,
create: Plus,
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 (profileCopyResetTimerRef.current !== null) {
window.clearTimeout(profileCopyResetTimerRef.current);
}
},
[],
);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
hasManualCategoryTagSelectionRef.current = false;
return;
}
const firstCategoryGroup =
categoryGroups.find((group) =>
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
) ?? categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
selectedCategoryGroup.entries.every((entry) =>
isMatch3DDemoProfileId(entry.profileId),
) &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
if (
selectedCategoryTag &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
hasManualCategoryTagSelectionRef.current = false;
}
}, [categoryGroups, selectedCategoryTag]);
useEffect(() => {
const missingAuthorKeys = [
...new Set(
publicEntries
.map(buildPublicWorkAuthorLookupKey)
.filter((key): key is string => Boolean(key)),
),
].filter(
(key) =>
!(key in publicAuthorSummariesByKey) &&
!pendingPublicAuthorKeysRef.current.has(key),
);
if (missingAuthorKeys.length === 0) {
return undefined;
}
let cancelled = false;
missingAuthorKeys.forEach((key) => {
pendingPublicAuthorKeysRef.current.add(key);
});
// 中文注释:头像来自公开用户摘要,失败时缓存空值,避免首页滚动时反复打公开用户接口。
void Promise.all(
missingAuthorKeys.map(async (authorLookupKey) => {
try {
const author = await getPublicWorkAuthorSummary(authorLookupKey);
return [authorLookupKey, author] as const;
} catch {
return [authorLookupKey, null] as const;
} finally {
pendingPublicAuthorKeysRef.current.delete(authorLookupKey);
}
}),
).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;
}
autoOpenedInviteQueryRef.current = true;
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
setReferralRedeemCode(pendingProfileInviteCode);
setReferralError(null);
setReferralSuccess(null);
setProfilePopupPanel('redeem');
}, [authUi, pendingProfileInviteCode]);
const scheduleProfileCopyStateReset = () => {
if (profileCopyResetTimerRef.current !== null) {
window.clearTimeout(profileCopyResetTimerRef.current);
}
profileCopyResetTimerRef.current = window.setTimeout(() => {
profileCopyResetTimerRef.current = null;
setProfileCopyState('idle');
}, 1400);
};
const copyProfilePublicUserCode = () => {
void copyTextToClipboard(publicUserCode).then((copied) => {
setProfileCopyState(copied ? 'copied' : 'failed');
scheduleProfileCopyStateReset();
});
};
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;
setNativeWechatPayment(null);
}, [loadRechargeCenter]);
const handleWechatPayResult = useCallback(() => {
const payResult = readWechatPayResultFromHash();
if (!payResult) {
return;
}
if (
pendingWechatRechargeOrderIdRef.current &&
payResult.orderId &&
payResult.orderId !== pendingWechatRechargeOrderIdRef.current
) {
return;
}
if (payResult.status === 'success') {
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '正在确认到账状态,请稍后查看余额或会员状态。',
});
if (payResult.orderId) {
void confirmWechatRechargeOrderUntilSettled(payResult.orderId)
.then((response) => {
const isPaid = response.order.status === 'paid';
setRechargeCenter(response.center);
setRechargePaymentResult(
isPaid
? {
kind: 'success',
title: '支付成功',
message: '已到账,账户状态已刷新。',
}
: {
kind: 'pending',
title: '支付已提交',
message: '正在等待微信支付确认,请稍后查看账户状态。',
},
);
if (isPaid) {
void onRechargeSuccess?.();
}
setSubmittingRechargeProductId(null);
pendingWechatRechargeOrderIdRef.current = null;
})
.catch(() => {
setRechargePaymentResult({
kind: 'pending',
title: '支付已提交',
message: '暂时没能确认到账状态,请稍后查看余额或会员状态。',
});
refreshRechargeState();
});
} else {
refreshRechargeState();
}
} else if (payResult.status === 'cancel') {
setRechargePaymentResult({
kind: 'cancel',
title: '支付已取消',
message: '本次没有扣款,账户状态未发生变化。',
});
refreshRechargeState();
} else {
setRechargePaymentResult({
kind: 'failed',
title: '支付未完成',
message: '微信支付没有完成,本次不会入账。',
});
refreshRechargeState();
}
clearWechatPayResultHash();
}, [onRechargeSuccess, refreshRechargeState]);
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);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
await requestWechatMiniProgramPayment(
response.wechatMiniProgramPayParams,
response.order.orderId,
);
setRechargeCenter(response.center);
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 handleResume = () => {
handleWechatPayResult();
};
window.addEventListener('hashchange', handleResume);
window.addEventListener('focus', handleResume);
window.addEventListener('pageshow', handleResume);
document.addEventListener('visibilitychange', handleResume);
handleResume();
return () => {
window.removeEventListener('hashchange', handleResume);
window.removeEventListener('focus', handleResume);
window.removeEventListener('pageshow', handleResume);
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
const loadTaskCenter = () => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then(setTaskCenter)
.catch((error: unknown) => {
setTaskCenter(null);
setTaskCenterError(
error instanceof Error ? error.message : '读取每日任务失败',
);
})
.finally(() => setIsLoadingTaskCenter(false));
};
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
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 ||
!isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt)
) {
setIsReferralCenterInitialized(false);
setReferralCenter(null);
return;
}
loadReferralCenter();
}, [activeTab, authUi?.user?.createdAt, 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 copyTextToClipboard(`${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 = () => {
const currentIndex = PLATFORM_CATEGORY_SORT_OPTIONS.findIndex(
(option) => option.id === categorySortMode,
);
const nextIndex =
currentIndex >= 0
? (currentIndex + 1) % PLATFORM_CATEGORY_SORT_OPTIONS.length
: 0;
setCategorySortMode(
PLATFORM_CATEGORY_SORT_OPTIONS[nextIndex]?.id ?? 'composite',
);
};
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 desktopRecommendEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
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 recommendedFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...generalLatestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, generalLatestEntries]);
const discoverFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
discoverChannel === 'recommend'
? recommendedFeedEntries
: filterTodayPublishedEntries(generalLatestEntries);
sourceEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [discoverChannel, generalLatestEntries, recommendedFeedEntries]);
const edutainmentFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
edutainmentEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [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 = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
const rankingEntries = useMemo(
() =>
buildPlatformRankingEntries(publicEntries, activeRankingTab).slice(0, 30),
[activeRankingTab, publicEntries],
);
const activeRecommendEntry =
recommendedFeedEntries.find(
(entry) => buildPublicGalleryCardKey(entry) === activeRecommendEntryKey,
) ??
recommendedFeedEntries[0] ??
null;
const activeRecommendIndex = activeRecommendEntry
? recommendedFeedEntries.findIndex(
(entry) =>
buildPublicGalleryCardKey(entry) ===
buildPublicGalleryCardKey(activeRecommendEntry),
)
: -1;
const previousRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex - 1 + recommendedFeedEntries.length) %
recommendedFeedEntries.length
]
: null;
const nextRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex + 1) % recommendedFeedEntries.length
]
: null;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const activeRecommendEntryKeyForSelection = activeRecommendEntry
? buildPublicGalleryCardKey(activeRecommendEntry)
: null;
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
startY: number;
dragging: boolean;
} | null>(null);
const commitRecommendDrag = useCallback(
(direction: 1 | -1) => {
if (recommendDragCommitDirection) {
return;
}
setRecommendDragCommitDirection(direction);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
setRecommendDragOffsetY(
direction === 1 ? -commitDistance : commitDistance,
);
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 ||
!isAuthenticated ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
return;
}
recommendDragStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
activeRecommendEntry,
isAuthenticated,
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 ||
Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
if (!drag.dragging) {
return;
}
event.preventDefault();
const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const dragLimit =
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
setRecommendDragOffsetY(Math.max(-dragLimit, Math.min(dragLimit, deltaY)));
}, []);
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;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
setRecommendDragOffsetY(0);
return;
}
commitRecommendDrag(deltaY < 0 ? 1 : -1);
},
[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 = recommendDragCommitDirection
? 'platform-recommend-swipe-rail--committing'
: recommendDragOffsetY === 0
? 'platform-recommend-swipe-rail--settled'
: 'platform-recommend-swipe-rail--dragging';
const selectNextRecommendEntry = useCallback(() => {
if (
isAuthenticated &&
activeRecommendEntry &&
recommendedFeedEntries.length > 1
) {
commitRecommendDrag(1);
return;
}
onSelectNextRecommendEntry?.(activeRecommendEntryKeyForSelection);
}, [
activeRecommendEntry,
activeRecommendEntryKeyForSelection,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback(
(entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
},
[],
);
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 ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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}
metricLabel={activeRankingConfig.metricLabel}
metricValue={getPlatformRankingMetricValue(
entry,
activeRankingTab,
)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text={activeRankingConfig.emptyText} />
)}
</section>
);
const mobileRecommendContent: ReactNode = (
<div
className={`${MOBILE_RECOMMEND_PAGE_STAGE_CLASS} platform-mobile-home-stage platform-mobile-recommend-stage`}
>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : 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>
) : isStartingRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
</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,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
/>
}
/>
</div>
) : null}
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
<RecommendSwipeCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
activeRecommendEntry,
)}
isActive
visual={
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>
{nextRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
<RecommendSwipeCard
entry={nextRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
nextRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
/>
}
/>
</div>
) : null}
</div>
</div>
) : (
<section className="platform-recommend-runtime-panel">
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
</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 ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-category-list-panel">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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>
) : (
<EmptyShelf text="当前筛选下没有作品。" />
)}
</>
) : (
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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)}
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>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
)}
</section>
) : (
<section
ref={mobileDiscoverFeedRef}
className="platform-mobile-home-feed"
>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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)}
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={
mobileCenteredCardKey === cardKey
}
/>
);
},
)}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</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 ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
{discoverChannel === 'ranking' ? (
mobileRankingPanel
) : discoverChannel === 'category' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取作品分类..." />
) : 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)}
/>
))}
</div>
) : (
<EmptyShelf text="当前筛选下没有作品。" />
)}
</>
) : (
<EmptyShelf text="暂时还没有可分类的作品。" />
)}
</section>
) : discoverChannel === 'edutainment' ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title={EDUTAINMENT_WORK_TAG} detail="EDUTAINMENT" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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)}
/>
))}
{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>
) : (
<EmptyShelf text="暂时还没有可展示的作品。" />
)}
</section>
) : (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={discoverChannel === 'today' ? '今日游戏' : '推荐'}
detail={discoverChannel === 'today' ? 'TODAY GAMES' : 'RECOMMENDED'}
/>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : 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)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</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">
<span className="platform-pill platform-pill--cool w-fit">
CREATE
</span>
<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="草稿与已发布" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : 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>
) : (
<EmptyShelf
text={
isAuthenticated
? '你还没有保存任何自定义世界,先创建一个草稿开始吧。'
: '登录后查看你的作品。'
}
/>
)}
</section>
</div>
);
const createContent: ReactNode =
createTabContent ?? fallbackCreateStartContent;
const savesContent: ReactNode = 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-[20px] 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-5 w-5" />
</button>
</div>
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
<span> {publicUserCode}</span>
<button
type="button"
onClick={copyProfilePublicUserCode}
className="platform-profile-copy-button"
>
{profileCopyState === 'copied'
? '已复制'
: profileCopyState === 'failed'
? '复制失败'
: '复制'}
</button>
</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-[18px] font-black leading-tight text-white">
</span>
<span className="platform-profile-membership-card__subtitle mt-2 block text-[13px] 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={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="累计游戏时长"
value={totalPlayTime}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="已玩游戏数量"
value={`${formatDashboardCount(playedWorkCount)}`}
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-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
<span className="text-[#c45b2a]">10</span>
</span>
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
0 / 1
</span>
<span className="platform-profile-daily-task-card__track">
<span className="platform-profile-daily-task-card__bar" />
</span>
</span>
</span>
<img
src={profileMascotImage}
alt=""
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
</span>
</button>
<section
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="platform-profile-shortcut-grid">
<ProfileShortcutButton
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="邀请好友"
subLabel="双方得 30 泥点"
icon={UserPlus}
imageSrc={profileInviteImage}
onClick={() => openProfilePopupPanel('invite')}
/>
<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={Palette}
onClick={() => authUi.openSettingsModal('appearance')}
/>
<ProfileSettingsRow
label="账号与安全"
icon={ShieldCheck}
onClick={() => authUi.openSettingsModal('account')}
/>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={() => authUi.openSettingsModal()}
/>
</section>
{canShowReferralRedeemShortcut ? (
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
</section>
) : null}
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
className="platform-button platform-button--primary mt-4"
>
</button>
</div>
</section>
)}
</div>
);
const desktopHomeContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : 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">
<span className="platform-pill platform-pill--warm">
</span>
<span className="platform-pill platform-pill--neutral px-3">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品'}
</span>
</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" />
<span className="platform-pill platform-pill--neutral px-3">
TODAY
</span>
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在读取今日游戏..." />
) : 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>
) : (
<EmptyShelf text="今天暂时还没有新游戏。" />
)}
</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 ? (
<EmptyShelf text="正在读取推荐作品..." />
) : 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)}
/>
))}
</div>
) : (
<EmptyShelf text="暂时还没有推荐作品。" />
)}
</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>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published'
? '已发布'
: '草稿'}
</span>
</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>
<span className="platform-pill platform-pill--neutral px-3">
</span>
</button>
);
})}
</div>
)}
</div>
</section>
) : null}
</div>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取作品分类..." />
) : 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)}
/>
))}
</div>
) : (
<EmptyShelf text="当前筛选下没有作品。" />
)}
</>
) : (
<EmptyShelf text="暂时还没有可分类的作品。" />
)}
</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 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
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' ? (
<button
type="button"
onClick={openUserSurface}
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={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<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>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : !isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
className="platform-button platform-button--primary shrink-0 px-3 py-2 text-xs"
>
<LogIn className="h-3.5 w-3.5" />
</button>
) : 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}
onClick={() => {
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}
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>
);
}
return (
<div 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' ? (
<button
type="button"
onClick={openUserSurface}
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={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<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>{formatDashboardCount(remainingNarrativeCoins)}</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}
onClick={() => {
onTabChange(tab);
}}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</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}
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}
</div>
);
}
export const PlatformHomeView = RpgEntryHomeView;