@@ -17,6 +17,8 @@ import { type ComponentType, useMemo } from 'react';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
ProfileDashboardCardKey,
|
||||
ProfileDashboardSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
@@ -72,7 +74,11 @@ function WorldCard({
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = [
|
||||
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
|
||||
return (
|
||||
@@ -224,19 +230,53 @@ 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);
|
||||
function formatCompactPlayTime(playTimeMs: number) {
|
||||
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
|
||||
const days = totalMinutes / 1440;
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`;
|
||||
if (days >= 10) {
|
||||
return `${Math.floor(days)}天`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分`;
|
||||
if (days >= 1) {
|
||||
return `${days.toFixed(days >= 3 ? 0 : 1)}天`;
|
||||
}
|
||||
return `${minutes}分`;
|
||||
|
||||
const hours = totalMinutes / 60;
|
||||
if (hours >= 1) {
|
||||
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
|
||||
}
|
||||
|
||||
return `${Math.max(0, totalMinutes)}分`;
|
||||
}
|
||||
|
||||
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 formatDashboardUpdatedAt(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 buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
@@ -249,7 +289,9 @@ function buildPublicUserCode(user: AuthUser | null | undefined) {
|
||||
}
|
||||
|
||||
function getUserAvatarLabel(user: AuthUser | null | undefined) {
|
||||
return (user?.displayName || user?.username || '叙').slice(0, 1).toUpperCase();
|
||||
return (user?.displayName || user?.username || '叙')
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
@@ -261,23 +303,40 @@ function copyText(value: string) {
|
||||
}
|
||||
|
||||
function ProfileStatCard({
|
||||
cardKey,
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
cardKey: ProfileDashboardCardKey;
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3 text-left transition hover:border-white/20 hover:bg-white/6"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStatCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-[1.35rem] border border-white/10 bg-black/18 px-4 py-3">
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +375,20 @@ export function PlatformHomeView({
|
||||
latestEntries,
|
||||
myEntries,
|
||||
historyEntries,
|
||||
historyError,
|
||||
profileDashboard,
|
||||
isLoadingPlatform,
|
||||
isLoadingDashboard,
|
||||
isClearingHistory,
|
||||
platformError,
|
||||
dashboardError,
|
||||
onContinueGame,
|
||||
onClearHistory,
|
||||
onOpenCreateWorld,
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onOpenProfileDashboardCard,
|
||||
}: {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -332,15 +398,22 @@ export function PlatformHomeView({
|
||||
latestEntries: CustomWorldGalleryCard[];
|
||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||
historyEntries: PlatformBrowseHistoryEntry[];
|
||||
historyError: string | null;
|
||||
profileDashboard: ProfileDashboardSummary | null;
|
||||
isLoadingPlatform: boolean;
|
||||
isLoadingDashboard: boolean;
|
||||
isClearingHistory: boolean;
|
||||
platformError: string | null;
|
||||
dashboardError: string | null;
|
||||
onContinueGame: () => void;
|
||||
onClearHistory: () => void;
|
||||
onOpenCreateWorld: () => void;
|
||||
onOpenCreateTypePicker: () => void;
|
||||
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
}) {
|
||||
const authUi = useAuthUi();
|
||||
const featuredShelf = useMemo(
|
||||
@@ -362,11 +435,11 @@ export function PlatformHomeView({
|
||||
'上一次冒险已经保存,可以从这里继续推进故事。';
|
||||
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 remainingNarrativeCoins = profileDashboard?.walletBalance ?? 0;
|
||||
const totalPlayTime = formatCompactPlayTime(
|
||||
profileDashboard?.totalPlayTimeMs ?? 0,
|
||||
);
|
||||
const playedWorkCount = hasSavedGame ? 1 : 0;
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const tabIcons = {
|
||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||
create: '/Icons/01_Scroll.png',
|
||||
@@ -647,21 +720,66 @@ export function PlatformHomeView({
|
||||
})}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
{isLoadingDashboard ? (
|
||||
<>
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
<ProfileStatCardSkeleton />
|
||||
</>
|
||||
) : dashboardError ? (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value="暂不可用"
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileStatCard
|
||||
cardKey="wallet"
|
||||
label="剩余叙世币"
|
||||
value={formatDashboardCount(remainingNarrativeCoins)}
|
||||
icon={Coins}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="总游戏时长"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="玩过作品"
|
||||
value={formatDashboardCount(playedWorkCount)}
|
||||
icon={BookOpen}
|
||||
onClick={onOpenProfileDashboardCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-zinc-500">
|
||||
{dashboardError
|
||||
? dashboardError
|
||||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -719,8 +837,27 @@ export function PlatformHomeView({
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<SectionHeader title="历史浏览" detail="最近看过的作品" />
|
||||
{historyEntries.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={isClearingHistory}
|
||||
className="shrink-0 rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{isClearingHistory ? '清空中' : '清空'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{historyError ? (
|
||||
<div className="mb-3 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{historyError}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoadingPlatform && historyEntries.length === 0 ? (
|
||||
<EmptyShelf text="正在读取浏览历史..." />
|
||||
) : historyEntries.length > 0 ? (
|
||||
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{historyEntries.map((entry) => (
|
||||
<button
|
||||
@@ -771,7 +908,9 @@ export function PlatformHomeView({
|
||||
作者:{entry.authorDisplayName}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
{entry.summaryText ||
|
||||
entry.subtitle ||
|
||||
'等待补充世界摘要。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,7 +954,9 @@ export function PlatformHomeView({
|
||||
<Settings className="h-[1.125rem] w-[1.125rem]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-white">设置</div>
|
||||
<div className="text-base font-semibold text-white">
|
||||
设置
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">账号与安全</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user