@@ -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>
|
||||
|
||||
@@ -12,12 +12,18 @@ import {
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
} from '../../services/aiService';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getProfileDashboard,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
listProfileBrowseHistory,
|
||||
upsertCustomWorldProfile,
|
||||
upsertProfileBrowseHistory,
|
||||
} from '../../services/storageService';
|
||||
import type { GameState } from '../../types';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
PreGameSelectionFlow,
|
||||
type SelectionStage,
|
||||
@@ -33,11 +39,16 @@ vi.mock('../../services/aiService', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
clearProfileBrowseHistory: vi.fn(),
|
||||
getCustomWorldGalleryDetail: vi.fn(),
|
||||
getProfileDashboard: vi.fn(),
|
||||
listCustomWorldGallery: vi.fn(),
|
||||
listCustomWorldLibrary: vi.fn(),
|
||||
listProfileBrowseHistory: vi.fn(),
|
||||
publishCustomWorldProfile: vi.fn(),
|
||||
syncProfileBrowseHistory: vi.fn(),
|
||||
unpublishCustomWorldProfile: vi.fn(),
|
||||
upsertProfileBrowseHistory: vi.fn(),
|
||||
upsertCustomWorldProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -108,11 +119,21 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
|
||||
updatedAt: '2026-04-14T12:00:00.000Z',
|
||||
};
|
||||
|
||||
function TestWrapper() {
|
||||
const mockAuthUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
|
||||
const [selectionStage, setSelectionStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
|
||||
return (
|
||||
const content = (
|
||||
<PreGameSelectionFlow
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={setSelectionStage}
|
||||
@@ -124,14 +145,41 @@ function TestWrapper() {
|
||||
handleCustomWorldSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!withAuth) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: mockAuthUser,
|
||||
openAccountModal: () => {},
|
||||
logout: async () => {},
|
||||
setGlobalAccountActionsVisible: () => {},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||
walletBalance: 0,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: '2026-04-16T12:00:00.000Z',
|
||||
});
|
||||
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
|
||||
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
@@ -350,26 +398,56 @@ test('existing draft sessions enter the legacy result layout directly', async ()
|
||||
await waitFor(
|
||||
async () => {
|
||||
expect(await screen.findByText('世界档案')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /保存到我的作品|自动保存中|已保存到我的作品/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('已自动保存')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /进入世界/u })).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /^锚点/u })).toBeNull();
|
||||
expect(screen.getByText(/原始设定/u)).toBeTruthy();
|
||||
expect(screen.getByText(/基本设定/u)).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /场景角色/u }));
|
||||
await user.click(screen.getByRole('button', { name: /顾潮音/u }));
|
||||
|
||||
expect(await screen.findByText(/编辑场景角色:顾潮音/u)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /AI生成形象与动作/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /AI生成/u })).toBeTruthy();
|
||||
expect(screen.getByText('技能')).toBeTruthy();
|
||||
|
||||
});
|
||||
|
||||
test('profile tab loads server browse history and can clear it after confirmation', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summaryText: '最近浏览过的公开作品。',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'tide',
|
||||
authorDisplayName: '潮汐作者',
|
||||
visitedAt: '2026-04-16T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '我的' }));
|
||||
|
||||
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('你最近还没有浏览过作品详情,去首页或发现逛一逛吧。'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user