@@ -1,14 +1,27 @@
|
||||
import { type ComponentType, useMemo } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Camera,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Crown,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Settings,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../../uiAssets';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import {
|
||||
@@ -20,43 +33,20 @@ import {
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './platformWorldPresentation';
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
title: string;
|
||||
detail: string;
|
||||
actionLabel?: string;
|
||||
onAction?: (() => void) | null;
|
||||
}) {
|
||||
export type PlatformHomeTab = 'home' | 'create' | 'profile';
|
||||
|
||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="mb-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||
<div className="mb-3">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
{detail}
|
||||
</div>
|
||||
{actionLabel && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="mt-1 text-base font-bold text-white">{title}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyShelf({
|
||||
text,
|
||||
}: {
|
||||
text: string;
|
||||
}) {
|
||||
function EmptyShelf({ text }: { text: string }) {
|
||||
return (
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel rounded-[1.35rem] text-sm leading-6 text-zinc-300"
|
||||
@@ -150,7 +140,173 @@ function WorldCard({
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformTabButton({
|
||||
active,
|
||||
label,
|
||||
iconSrc,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
iconSrc: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex h-full w-full items-center justify-center rounded-[1rem] px-2 py-1.5 transition ${
|
||||
active
|
||||
? 'border border-white/15 bg-white/8 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.08)]'
|
||||
: 'text-zinc-400 hover:bg-white/5 hover:text-zinc-100'
|
||||
}`}
|
||||
>
|
||||
<span className="flex flex-col items-center justify-center gap-1">
|
||||
<span
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full ${
|
||||
active
|
||||
? 'bg-white/10 shadow-[0_0_14px_rgba(255,255,255,0.14)]'
|
||||
: 'bg-black/10'
|
||||
}`}
|
||||
>
|
||||
<PixelIcon
|
||||
src={iconSrc}
|
||||
className={`h-[1.125rem] w-[1.125rem] ${
|
||||
active ? 'opacity-100' : 'opacity-65 grayscale'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-semibold tracking-[0.18em] ${
|
||||
active ? 'text-white' : 'text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'phone':
|
||||
return '手机号';
|
||||
case 'wechat':
|
||||
return '微信';
|
||||
default:
|
||||
return '账号密码';
|
||||
}
|
||||
}
|
||||
|
||||
function describeBindingStatus(bindingStatus: AuthUser['bindingStatus']) {
|
||||
return bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '正常';
|
||||
}
|
||||
|
||||
function formatPlayTime(playTimeMs: number) {
|
||||
const totalSeconds = Math.max(0, Math.floor(playTimeMs / 1000));
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分`;
|
||||
}
|
||||
return `${minutes}分`;
|
||||
}
|
||||
|
||||
function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
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 copyText(value: string) {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
function ProfileStatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-white">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileShortcutButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick?: (() => void) | null;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] border border-white/10 bg-black/16 px-3 py-3 text-center transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformHomeView({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
hasSavedGame,
|
||||
savedSnapshot,
|
||||
featuredEntries,
|
||||
@@ -159,11 +315,13 @@ export function PlatformHomeView({
|
||||
isLoadingPlatform,
|
||||
platformError,
|
||||
onContinueGame,
|
||||
onRefresh,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
hasSavedGame: boolean;
|
||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||
featuredEntries: CustomWorldGalleryCard[];
|
||||
@@ -172,12 +330,18 @@ export function PlatformHomeView({
|
||||
isLoadingPlatform: boolean;
|
||||
platformError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onRefresh: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
);
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
@@ -186,169 +350,401 @@ export function PlatformHomeView({
|
||||
savedSnapshot?.gameState.playerCharacter?.title ??
|
||||
savedSnapshot?.gameState.playerCharacter?.name ??
|
||||
'旅人';
|
||||
const featuredShelf = featuredEntries.slice(0, 6);
|
||||
const snapshotDigest =
|
||||
savedSnapshot?.gameState.storyEngineMemory?.continueGameDigest ??
|
||||
savedSnapshot?.currentStory?.text ??
|
||||
savedSnapshot?.gameState.customWorldProfile?.summary ??
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
const publicUserCode = buildPublicUserCode(authUi?.user);
|
||||
const avatarLabel = getUserAvatarLabel(authUi?.user);
|
||||
const remainingNarrativeCoins = savedSnapshot?.gameState.playerCurrency ?? 0;
|
||||
const totalPlayTime = formatPlayTime(
|
||||
savedSnapshot?.gameState.runtimeStats.playTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = hasSavedGame ? 1 : 0;
|
||||
const tabIcons = {
|
||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||
create: '/Icons/01_Scroll.png',
|
||||
profile: '/UI/Icon_Eq_Head.png',
|
||||
} as const;
|
||||
const recentPlayItems = savedSnapshot
|
||||
? [
|
||||
{
|
||||
id: 'latest-save',
|
||||
title: snapshotWorldName,
|
||||
subtitle: snapshotCharacterName,
|
||||
summary: snapshotDigest,
|
||||
updatedAt: savedSnapshot.savedAt,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-amber-300/20 bg-amber-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
GENARRATIVE PLATFORM
|
||||
let content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
<div className="truncate text-lg font-black text-white">
|
||||
自定义世界广场
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '写下一个能被游玩的世界'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
|
||||
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-200 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
{authUi?.user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="rounded-full border border-white/10 bg-black/25 px-3 py-2 text-[11px] text-zinc-100 transition hover:border-white/20 hover:text-white"
|
||||
</button>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="精选推荐" detail="为你挑选" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: CustomWorldGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (activeTab === 'create') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateTypePicker}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_38%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<span className="w-fit rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
CREATE
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">开启新的创作</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
先选择游戏类型,再进入对应的创作工作台继续推进。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的创作" detail="草稿与已发布" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取你的作品..." />
|
||||
) : myEntries.length > 0 ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{myEntries.map(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={
|
||||
entry.visibility === 'published'
|
||||
? formatPlatformWorldTime(entry.publishedAt)
|
||||
: '仅自己可见'
|
||||
}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 'profile') {
|
||||
content = (
|
||||
<div className="space-y-4 pb-2">
|
||||
{authUi?.user ? (
|
||||
<>
|
||||
<section className="overflow-hidden rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(248,244,236,0.96),rgba(232,225,214,0.92))] p-4 text-slate-900 shadow-[0_18px_50px_rgba(0,0,0,0.18)]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="relative h-16 w-16 shrink-0 rounded-[1.4rem] bg-[linear-gradient(135deg,#2a3141,#66718a)] text-white shadow-[0_12px_24px_rgba(15,23,42,0.22)]"
|
||||
>
|
||||
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
|
||||
{avatarLabel}
|
||||
</span>
|
||||
<span className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-white/30 bg-white/85 text-slate-700">
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-xl font-black text-slate-900">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/6 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span>叙世号 {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(publicUserCode)}
|
||||
className="flex items-center gap-1 rounded-full bg-slate-900/6 px-2 py-1 text-slate-700 transition hover:bg-slate-900/10"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
{describeLoginMethod(authUi.user.loginMethod)}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
|
||||
{describeBindingStatus(authUi.user.bindingStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#5d79ff,#8ba2ff)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(93,121,255,0.28)]"
|
||||
>
|
||||
<Crown className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">会员充值</div>
|
||||
<div className="text-[10px] text-white/80">普通用户</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
{authUi.user.displayName}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileStatCard
|
||||
label="剩余叙世币"
|
||||
value={`${remainingNarrativeCoins}`}
|
||||
icon={Coins}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
label="玩过作品"
|
||||
value={`${playedWorkCount}`}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="最近游玩" detail="继续上次进度" />
|
||||
{recentPlayItems.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{recentPlayItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={onContinueGame}
|
||||
className="relative flex h-[10.5rem] w-[17rem] shrink-0 overflow-hidden rounded-[1.4rem] border border-white/10 bg-[linear-gradient(135deg,rgba(25,32,46,0.95),rgba(9,12,18,0.96))] p-4 text-left"
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(96,165,250,0.18),transparent_35%),radial-gradient(circle_at_bottom_left,rgba(250,204,21,0.12),transparent_28%)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
RECENT PLAY
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400">
|
||||
{formatSnapshotTime(item.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-white">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-300">
|
||||
当前角色:{item.subtitle}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{item.summary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有最近游玩的存档,去首页挑一个世界开始冒险吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="常用功能" detail="快捷入口" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProfileShortcutButton label="邀请好友" icon={UserPlus} />
|
||||
<ProfileShortcutButton label="填邀请码" icon={Ticket} />
|
||||
<ProfileShortcutButton label="玩家社区" icon={MessageCircle} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authUi.openAccountModal()}
|
||||
className="flex w-full items-center justify-between gap-3 rounded-[1.25rem] border border-white/10 bg-black/16 px-4 py-4 text-left transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-xs text-zinc-400">账号与安全</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="当前还没有读取到账户信息。" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="mb-4">
|
||||
<div className="text-lg font-black text-white">叙世</div>
|
||||
<div className="mt-1 text-[10px] tracking-[0.28em] text-zinc-500">
|
||||
GENARRATIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
|
||||
<div className="space-y-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative block w-full overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(250,204,21,0.16),transparent_36%),linear-gradient(135deg,rgba(15,23,42,0.78),rgba(8,10,14,0.95))]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
|
||||
{hasSavedGame ? 'CONTINUE' : 'CREATE'}
|
||||
</span>
|
||||
<div className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] text-zinc-100">
|
||||
{hasSavedGame ? '继续冒险' : '创建世界'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl font-black text-white">
|
||||
{hasSavedGame ? snapshotWorldName : '把第一页变成你的作品页'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
|
||||
{hasSavedGame
|
||||
? `${snapshotCharacterName} 的上一次冒险已保存在云端,点这里直接回到故事现场。`
|
||||
: '从设定、角色到场景网络,一次生成一部可游玩的自定义 RPG,再决定是否发布到广场。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{content}
|
||||
</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-100">
|
||||
{platformError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title="精选推荐"
|
||||
detail="为你挑选"
|
||||
actionLabel="看看最新"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:featured`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePlatformThemeLabel(entry.themeMode)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="还没有公开作品,先创建你的第一个世界吧。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:latest`}
|
||||
entry={entry}
|
||||
badge={formatPlatformWorldTime(entry.publishedAt)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="我的作品" detail="草稿与已发布" />
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenCreateWorld}
|
||||
className="pixel-nine-slice pixel-pressable relative min-h-[13rem] overflow-hidden text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 18, paddingY: 16 })}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.92))]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
|
||||
<PixelIcon src={CHROME_ICONS.refreshOptions} className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="text-2xl font-black text-white">
|
||||
创建新世界
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-300">
|
||||
新建一个只属于你的世界设定,生成后先进入草稿库,再决定要不要发布。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{myEntries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
|
||||
metaLabel={entry.visibility === 'published' ? formatPlatformWorldTime(entry.publishedAt) : '仅自己可见'}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isLoadingPlatform && myEntries.length === 0 ? (
|
||||
<div className="mt-3">
|
||||
<EmptyShelf text="你还没有保存任何自定义世界,先创建一个草稿开始吧。" />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<div
|
||||
className="mt-4 border-t border-white/5 pt-3"
|
||||
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
|
||||
>
|
||||
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
iconSrc={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'create'}
|
||||
label="创作"
|
||||
iconSrc={tabIcons.create}
|
||||
onClick={() => onTabChange('create')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'profile'}
|
||||
label="我的"
|
||||
iconSrc={tabIcons.profile}
|
||||
onClick={() => onTabChange('profile')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user