11
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-16 21:47:20 +08:00
parent 2456c10c63
commit 09d4c0c31b
79 changed files with 11873 additions and 2341 deletions

View File

@@ -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>

View File

@@ -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