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

This commit is contained in:
2026-04-20 09:54:17 +08:00
parent 67c584b4df
commit 50759f3c1e
159 changed files with 16938 additions and 16925 deletions

View File

@@ -7,6 +7,7 @@ import type {
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -44,7 +45,7 @@ const GameShellStoryPanels = lazy(async () => {
function MainContentLoadingFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
{label}
</div>
</div>
@@ -80,6 +81,7 @@ export function GameShellMainContent({
inventoryUi,
battleRewardUi,
questUi,
npcChatQuestOfferUi,
goalUi,
companionRenderStates,
characterChatSummaries,
@@ -120,6 +122,7 @@ export function GameShellMainContent({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
@@ -214,6 +217,7 @@ export function GameShellMainContent({
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
npcChatQuestOfferUi={npcChatQuestOfferUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}

View File

@@ -47,6 +47,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi,
battleRewardUi,
questUi,
npcChatQuestOfferUi,
goalUi,
} = story;
const {
@@ -169,6 +170,7 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
npcChatQuestOfferUi={npcChatQuestOfferUi}
goalUi={goalUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}

View File

@@ -6,6 +6,7 @@ import type {
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
@@ -57,6 +58,7 @@ export function GameShellStoryPanels({
inventoryUi,
battleRewardUi,
questUi,
npcChatQuestOfferUi,
goalUi,
companionRenderStates,
characterChatSummaries,
@@ -85,6 +87,7 @@ export function GameShellStoryPanels({
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
@@ -193,6 +196,7 @@ export function GameShellStoryPanels({
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
npcChatQuestOfferUi={npcChatQuestOfferUi}
goalStack={goalUi.goalStack}
goalPulse={goalUi.pulse}
onDismissGoalPulse={goalUi.dismissPulse}

View File

@@ -0,0 +1,19 @@
export function PlatformBrandLogo({
className = '',
decorative = false,
}: {
className?: string;
decorative?: boolean;
}) {
return (
<span
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);
}

View File

@@ -33,6 +33,7 @@ import type { AuthUser } from '../../services/authService';
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformBrandLogo } from './PlatformBrandLogo';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
@@ -47,6 +48,8 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card';
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
@@ -67,6 +70,39 @@ function EmptyShelf({ text }: { text: string }) {
);
}
function SaveArchivePreview({
entry,
label,
className,
}: {
entry: ProfileSaveArchiveSummary;
label: string;
className: string;
}) {
return (
<div
aria-hidden="true"
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
>
{entry.coverImageSrc ? (
<img
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-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
{label}
</span>
</div>
</div>
);
}
function WorldCard({
entry,
badge,
@@ -155,6 +191,92 @@ function WorldCard({
);
}
function CreationLibraryCard({
entry,
onClick,
}: {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const statusLabel = entry.visibility === 'published' ? '已发布' : '草稿';
const metaLabel =
entry.visibility === 'published'
? formatPlatformWorldTime(entry.publishedAt)
: '仅自己可见';
const primaryTag =
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean)[0] ?? describePlatformThemeLabel(entry.themeMode);
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 ? (
<img
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<img
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-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
<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-zinc-300">
<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-white sm:text-[1.12rem]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 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-zinc-100/90">
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</span>
</div>
</div>
</div>
</button>
);
}
function SaveArchiveCard({
entry,
onClick,
@@ -164,40 +286,46 @@ function SaveArchiveCard({
onClick: () => void;
loading?: boolean;
}) {
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
return (
<button
type="button"
onClick={onClick}
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[12.5rem] w-full overflow-hidden p-4 text-left ${loading ? 'opacity-80' : ''}`}
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' : ''}`}
>
{entry.coverImageSrc ? (
<img
src={entry.coverImageSrc}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-24"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.08),rgba(8,10,14,0.92))]" />
<div className="relative z-10 flex h-full w-full flex-col">
<div className="flex items-start justify-between gap-3">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
<span className="text-[11px] text-zinc-400">
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-sm text-zinc-300">
{entry.subtitle}
<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.15rem] font-black leading-tight text-white sm:text-xl">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 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>
) : null}
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
{entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。'}
</div>
<SaveArchivePreview
entry={entry}
label={loading ? '恢复中' : '最近存档'}
className="h-[7.4rem] w-[5.6rem] sm:h-[8rem] sm:w-[6.4rem]"
/>
</div>
</div>
</button>
@@ -222,24 +350,10 @@ function PlatformTabButton({
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<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-[rgba(255,255,255,0.78)] text-[var(--platform-text-strong)] shadow-[0_12px_24px_rgba(255,91,132,0.16)]'
: 'bg-[rgba(255,255,255,0.46)] text-[var(--platform-text-soft)]'
}`}
>
<Icon
className={`h-[1.05rem] w-[1.05rem] ${
active ? 'text-violet-100' : 'text-zinc-400'
}`}
/>
<span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
</span>
<span
className={`text-[11px] font-semibold tracking-[0.18em] ${
active ? 'text-white' : 'text-zinc-400'
}`}
>
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
{label}
</span>
</span>
@@ -264,18 +378,10 @@ function DesktopTabButton({
onClick={onClick}
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
>
<span
className={`flex h-11 w-11 items-center justify-center rounded-full ${
active ? 'bg-white/18 text-white' : 'bg-white/58 text-zinc-500'
}`}
>
<Icon className="h-[1.1rem] w-[1.1rem]" />
<span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
</span>
<span
className={`text-[11px] font-semibold tracking-[0.2em] ${
active ? 'text-white' : 'text-zinc-400'
}`}
>
<span className="platform-desktop-rail__label text-[11px] font-semibold tracking-[0.2em]">
{label}
</span>
</button>
@@ -469,7 +575,7 @@ function ProfileStatCard({
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-2 text-zinc-400">
<Icon className="h-4 w-4" />
@@ -504,9 +610,9 @@ function ProfileShortcutButton({
<button
type="button"
onClick={onClick ?? undefined}
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/8 text-zinc-100">
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Icon className="h-[1.125rem] w-[1.125rem]" />
</div>
<div className="text-sm font-semibold text-white">{label}</div>
@@ -549,17 +655,14 @@ export function PlatformHomeView({
latestEntries: CustomWorldGalleryCard[];
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
historyEntries: PlatformBrowseHistoryEntry[];
historyError: string | null;
profileDashboard: ProfileDashboardSummary | null;
isLoadingPlatform: boolean;
isLoadingDashboard: boolean;
isClearingHistory: boolean;
isResumingSaveWorldKey: string | null;
platformError: string | null;
dashboardError: string | null;
onContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
onClearHistory: () => void;
onOpenCreateWorld: () => void;
onOpenCreateTypePicker: () => void;
onOpenGalleryDetail: (entry: CustomWorldGalleryCard) => void;
@@ -622,10 +725,17 @@ export function PlatformHomeView({
const desktopLibraryPreview = myEntries.slice(0, 2);
let content = (
<div className="space-y-4 pb-2">
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
onClick={() => {
if (hasSavedGame) {
onContinueGame();
return;
}
onOpenCreateWorld();
}}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
@@ -707,7 +817,7 @@ export function PlatformHomeView({
if (activeTab === 'create') {
content = (
<div className="space-y-4 pb-2">
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
type="button"
onClick={onOpenCreateTypePicker}
@@ -736,18 +846,12 @@ export function PlatformHomeView({
{isLoadingPlatform ? (
<EmptyShelf text="正在读取你的作品..." />
) : myEntries.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 xl:grid-cols-3">
{myEntries.map(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => (
<WorldCard
<CreationLibraryCard
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
entry={entry}
badge={entry.visibility === 'published' ? '已发布' : '草稿'}
metaLabel={
entry.visibility === 'published'
? formatPlatformWorldTime(entry.publishedAt)
: '仅自己可见'
}
onClick={() => onOpenLibraryDetail(entry)}
/>
),
@@ -769,22 +873,15 @@ export function PlatformHomeView({
if (activeTab === 'saves') {
content = (
<div className="space-y-4 pb-2">
<div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? (
<>
<section
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
>
{latestSaveEntry?.coverImageSrc ? (
<img
src={latestSaveEntry.coverImageSrc}
alt={latestSaveEntry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-28"
/>
) : null}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">
SAVE ARCHIVE
</span>
@@ -792,15 +889,24 @@ export function PlatformHomeView({
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
</div>
</div>
<div>
<div className="text-3xl font-black text-white">
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
</div>
<div className="mt-2 max-w-[30rem] text-sm leading-6 text-zinc-200/88">
{latestSaveEntry
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
: '你在平台里留下的最近可恢复存档会显示在这里。'}
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
{latestSaveEntry
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
: '你在平台里留下的最近可恢复存档会显示在这里。'}
</div>
</div>
{latestSaveEntry ? (
<SaveArchivePreview
entry={latestSaveEntry}
label="最近更新"
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
/>
) : null}
</div>
</div>
</section>
@@ -851,54 +957,54 @@ export function PlatformHomeView({
if (activeTab === 'profile') {
content = (
<div className="space-y-4 pb-2">
<div className={MOBILE_PAGE_STAGE_CLASS}>
{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)]">
<section className="platform-profile-hero rounded-[1.8rem] p-4">
<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)]"
>
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
>
<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">
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
<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">
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
{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"
>
<button
type="button"
onClick={() => authUi.openAccountModal()}
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
>
<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">
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<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"
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
>
<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">
<span className="platform-profile-chip rounded-full px-2.5 py-1">
{describeLoginMethod(authUi.user.loginMethod)}
</span>
<span className="rounded-full bg-slate-900/6 px-2.5 py-1 text-slate-700">
<span className="platform-profile-chip rounded-full px-2.5 py-1">
{describeBindingStatus(authUi.user.bindingStatus)}
</span>
</div>
@@ -907,12 +1013,12 @@ export function PlatformHomeView({
<button
type="button"
className="flex shrink-0 items-center gap-2 rounded-[1.1rem] bg-[linear-gradient(135deg,#ff4f8b,#ff8a73)] px-3 py-2 text-left text-white shadow-[0_12px_24px_rgba(255,79,139,0.24)]"
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
<Crown className="h-4 w-4" />
<div>
<div className="text-xs font-bold"></div>
<div className="text-[10px] text-white/80"></div>
<div className="text-[10px] opacity-80"></div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
@@ -997,18 +1103,16 @@ export function PlatformHomeView({
<button
type="button"
onClick={() => authUi.openSettingsModal()}
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-white/92"
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<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">
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<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>
<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>
@@ -1034,7 +1138,7 @@ export function PlatformHomeView({
const desktopContent =
activeTab === 'home' ? (
<div className="space-y-5 pb-4">
<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-100">
{platformError}
@@ -1044,7 +1148,14 @@ export function PlatformHomeView({
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.55fr)_22rem]">
<button
type="button"
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
onClick={() => {
if (hasSavedGame) {
onContinueGame();
return;
}
onOpenCreateWorld();
}}
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
>
{desktopHeroCover ? (
@@ -1172,7 +1283,14 @@ export function PlatformHomeView({
/>
<button
type="button"
onClick={hasSavedGame ? onContinueGame : onOpenCreateWorld}
onClick={() => {
if (hasSavedGame) {
onContinueGame();
return;
}
onOpenCreateWorld();
}}
className="platform-surface platform-surface--soft platform-interactive-card relative block w-full overflow-hidden px-5 py-5 text-left"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,164,198,0.18),transparent_28%),radial-gradient(circle_at_bottom_left,rgba(255,202,176,0.18),transparent_32%)]" />
@@ -1315,10 +1433,7 @@ export function PlatformHomeView({
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col lg:hidden">
<div className="mb-4">
<div className="platform-brand-logo" aria-label="叙世 GENARRATIVE">
<div className="platform-brand-logo__title"></div>
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
</div>
<PlatformBrandLogo />
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
@@ -1362,10 +1477,10 @@ export function PlatformHomeView({
<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">
<div className="platform-brand-logo shrink-0" aria-hidden="true">
<div className="platform-brand-logo__title"></div>
<div className="platform-brand-logo__subtitle">GENARRATIVE</div>
</div>
<PlatformBrandLogo
className="shrink-0"
decorative
/>
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">

View File

@@ -13,6 +13,7 @@ import {
getCustomWorldAgentSession,
streamCustomWorldAgentMessage,
} from '../../services/aiService';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
@@ -29,14 +30,30 @@ import {
} from '../../services/storageService';
import type { GameState } from '../../types';
import {
type PlatformSettingsSection,
AuthUiContext,
type PlatformSettingsSection,
} from '../auth/AuthUiContext';
import {
PreGameSelectionFlow,
type SelectionStage,
} from './PreGameSelectionFlow';
async function clickFirstButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
) {
const buttons = screen.getAllByRole('button', { name });
await user.click(buttons[0]!);
}
async function clickFirstAsyncButtonByName(
user: ReturnType<typeof userEvent.setup>,
name: string | RegExp,
) {
const buttons = await screen.findAllByRole('button', { name });
await user.click(buttons[0]!);
}
vi.mock('../../services/aiService', () => ({
createCustomWorldAgentSession: vi.fn(),
executeCustomWorldAgentAction: vi.fn(),
@@ -204,6 +221,26 @@ type TestAuthValue = {
settingsError: string | null;
};
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
return {
user: mockAuthUser,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
function TestWrapper({
withAuth = false,
authValue,
@@ -211,7 +248,7 @@ function TestWrapper({
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: unknown) => void;
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
@@ -235,24 +272,7 @@ function TestWrapper({
return (
<AuthUiContext.Provider
value={
authValue ?? {
user: mockAuthUser,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}
}
value={authValue ?? createAuthValue()}
>
{content}
</AuthUiContext.Provider>
@@ -292,7 +312,7 @@ beforeEach(() => {
bottomTab: 'adventure',
currentStory: null,
gameState: {} as GameState,
},
} as HydratedSavedGameSnapshot,
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
@@ -351,8 +371,8 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
expect(screen.getByText('选择创作类型')).toBeTruthy();
@@ -399,14 +419,11 @@ test('clicking a public work while logged out routes through requireAuth', async
render(
<TestWrapper
authValue={{
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
})}
/>,
);
@@ -425,19 +442,16 @@ test('selecting RPG creation while logged out routes through requireAuth', async
render(
<TestWrapper
authValue={{
authValue={createAuthValue({
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
})}
/>,
);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
expect(requireAuth).toHaveBeenCalledTimes(1);
@@ -449,8 +463,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
expect(
@@ -582,8 +596,8 @@ test('existing draft sessions enter the legacy result layout directly', async ()
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await clickFirstButtonByName(user, '创作');
await clickFirstButtonByName(user, //u);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(
@@ -608,8 +622,6 @@ test('existing draft sessions enter the legacy result layout directly', async ()
});
test('authenticated users with save archives default into the saves tab', async () => {
const user = userEvent.setup();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
@@ -626,9 +638,9 @@ test('authenticated users with save archives default into the saves tab', async
render(<TestWrapper withAuth />);
expect(await screen.findByText('全部存档')).toBeTruthy();
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0);
});
test('save tab can resume a selected archive directly into the game', async () => {
@@ -668,12 +680,12 @@ test('save tab can resume a selected archive directly into the game', async () =
gameState: {
worldType: 'CUSTOM',
} as GameState,
},
} as HydratedSavedGameSnapshot,
});
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await user.click(await screen.findByRole('button', { name: //u }));
await clickFirstAsyncButtonByName(user, //u);
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
@@ -719,8 +731,8 @@ test('owned world detail can delete a work and return to the create tab list', a
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByRole('button', { name: //u }));
await clickFirstButtonByName(user, '创作');
await clickFirstAsyncButtonByName(user, //u);
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
@@ -731,6 +743,7 @@ test('owned world detail can delete a work and return to the create tab list', a
expect(screen.queryByRole('button', { name: '删除作品' })).toBeNull();
});
expect(
screen.getByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。'),
screen.getAllByText('你还没有保存任何自定义世界,先创建一个草稿开始吧。')
.length,
).toBeTruthy();
});

View File

@@ -47,7 +47,6 @@ import {
buildCustomWorldCreatorIntentFoundationText,
} from '../../services/customWorldCreatorIntent';
import {
clearPlatformBrowseHistory,
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
type PlatformBrowseHistoryEntry,
@@ -56,7 +55,6 @@ import {
writePlatformBrowseHistory,
} from '../../services/platformBrowseHistory';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
@@ -171,7 +169,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
{label}
</div>
</div>
@@ -230,12 +228,11 @@ export function PreGameSelectionFlow({
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [_historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
@@ -1049,31 +1046,6 @@ export function PreGameSelectionFlow({
}
};
const handleClearBrowseHistory = async () => {
if (isClearingHistory || historyEntries.length === 0) {
return;
}
const confirmed = window.confirm('确认清空全部浏览历史吗?');
if (!confirmed) {
return;
}
setIsClearingHistory(true);
setHistoryError(null);
try {
clearPlatformBrowseHistory(authUi?.user);
if (authUi?.user) {
await clearProfileBrowseHistory();
}
setHistoryEntries([]);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
} finally {
setIsClearingHistory(false);
}
};
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!authUi?.user || isResumingSaveWorldKey) {
@@ -1318,11 +1290,9 @@ export function PreGameSelectionFlow({
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
historyEntries={historyEntries}
historyError={historyError}
profileDashboard={profileDashboard}
isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard}
isClearingHistory={isClearingHistory}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
@@ -1332,9 +1302,6 @@ export function PreGameSelectionFlow({
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onClearHistory={() => {
void handleClearBrowseHistory();
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
@@ -1366,7 +1333,7 @@ export function PreGameSelectionFlow({
>
{isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
{detailError || '正在读取作品详情...'}
</div>
</div>
@@ -1452,7 +1419,7 @@ export function PreGameSelectionFlow({
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="rounded-2xl border border-white/10 bg-black/30 px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
{isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: creationTypeError || '正在恢复创作工作区...'}

View File

@@ -4,6 +4,7 @@ import type {
CharacterChatUi,
GoalFlowUi,
InventoryFlowUi,
NpcChatQuestOfferUi,
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
@@ -41,6 +42,7 @@ export interface GameShellStoryProps {
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
npcChatQuestOfferUi: NpcChatQuestOfferUi;
goalUi: GoalFlowUi;
}