This commit is contained in:
2026-04-24 16:15:00 +08:00
parent f65177b147
commit b355568189
16 changed files with 495 additions and 217 deletions

View File

@@ -15,11 +15,11 @@ import {
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
authEntry,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
authEntry,
bindWechatPhone,
changePassword,
changePhoneNumber,
@@ -38,6 +38,7 @@ import {
resetPassword,
revokeAuthSession,
sendPhoneLoginCode,
setStoredLastLoginPhone,
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
@@ -694,6 +695,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null);
activateReadyUser(nextUser);
} catch (loginError) {
@@ -711,6 +713,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
try {
const nextUser = await authEntry(username, password);
setStoredLastLoginPhone(username);
activateReadyUser(nextUser);
} catch (loginError) {
setError(
@@ -727,6 +730,7 @@ export function AuthGate({ children }: AuthGateProps) {
setError('');
try {
const nextUser = await resetPassword(phone, code, newPassword);
setStoredLastLoginPhone(phone);
activateReadyUser(nextUser);
} catch (resetError) {
setError(

View File

@@ -6,6 +6,7 @@ import type {
AuthCaptchaChallenge,
AuthLoginMethod,
} from '../../services/authService';
import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
@@ -57,11 +58,9 @@ export function LoginScreen({
onResetPassword,
onStartWechatLogin,
}: LoginScreenProps) {
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
const [username, setUsername] = useState('');
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [resetPhone, setResetPhone] = useState('');
const [resetCode, setResetCode] = useState('');
@@ -154,75 +153,55 @@ export function LoginScreen({
/>
) : (
<div className="flex flex-col gap-4 px-5 py-5">
<div className="grid grid-cols-2 gap-2 rounded-full bg-[var(--platform-subpanel-bg)] p-1">
<TabButton
active={activeTab === 'login'}
label="登录"
onClick={() => setActiveTab('login')}
/>
<TabButton
active={activeTab === 'register'}
label="注册"
onClick={() => setActiveTab('register')}
/>
</div>
{activeTab === 'login' ? (
{passwordLoginEnabled ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (!passwordLoginEnabled) {
return;
}
void onPasswordSubmit(username, password);
void onPasswordSubmit(phone, password);
}}
>
{passwordLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="用户名"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
</>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="输入密码"
/>
</label>
{error ? <ErrorBanner message={error} /> : null}
{passwordLoginEnabled ? (
<div className="flex flex-col gap-2">
<button
type="submit"
disabled={submitDisabled || !username.trim() || !password.trim()}
disabled={submitDisabled || !phone.trim() || !password.trim()}
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '登录中' : '登录'}
{loggingIn ? '登录中' : '注册/登录'}
</button>
) : null}
<button
type="button"
className="self-center text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
<button
type="button"
className="self-end text-sm text-[var(--platform-accent)]"
onClick={() => setIsResetPanelOpen(true)}
>
</button>
</div>
{wechatLoginEnabled ? (
<WechatButton
@@ -232,7 +211,9 @@ export function LoginScreen({
/>
) : null}
</form>
) : (
) : null}
{phoneLoginEnabled ? (
<PhoneCodeForm
phone={phone}
code={code}
@@ -243,8 +224,9 @@ export function LoginScreen({
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册登录"
submitLabel="注册/登录"
enabled={phoneLoginEnabled}
showPhoneField={!passwordLoginEnabled}
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
@@ -262,7 +244,7 @@ export function LoginScreen({
}}
onSubmit={() => onPhoneSubmit(phone, code)}
/>
)}
) : null}
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
@@ -276,30 +258,6 @@ export function LoginScreen({
);
}
function TabButton({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={`h-10 rounded-full text-sm font-medium transition ${
active
? 'bg-[var(--platform-panel-bg)] text-[var(--platform-text-strong)] shadow-sm'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
{label}
</button>
);
}
function PhoneCodeForm({
phone,
code,
@@ -312,6 +270,7 @@ function PhoneCodeForm({
hint,
submitLabel,
enabled,
showPhoneField,
onPhoneChange,
onCodeChange,
onCaptchaAnswerChange,
@@ -329,6 +288,7 @@ function PhoneCodeForm({
hint: string;
submitLabel: string;
enabled: boolean;
showPhoneField: boolean;
onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void;
@@ -347,17 +307,19 @@ function PhoneCodeForm({
void onSubmit();
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
{showPhoneField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => onPhoneChange(event.target.value)}
placeholder="13800000000"
/>
</label>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>

View File

@@ -17,19 +17,23 @@ export function CustomWorldCreationStartCard({
onCreateType,
}: CustomWorldCreationStartCardProps) {
return (
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 space-y-4">
<div>
<div className="text-2xl font-black text-white sm:text-3xl">
<div className="relative z-10 space-y-2.5 sm:space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xl font-black leading-none text-white sm:text-3xl">
</div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block">
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy ? '正在开启' : '选择模板'}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => {
const disabled = item.locked || busy;
@@ -41,15 +45,15 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative overflow-hidden rounded-[1.5rem] border px-4 py-4 text-left transition ${
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
<span
className={`platform-pill px-3 ${
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
@@ -64,11 +68,11 @@ export function CustomWorldCreationStartCard({
)}
</div>
<div className="mt-7 text-lg font-black leading-tight text-inherit">
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
@@ -80,7 +84,7 @@ export function CustomWorldCreationStartCard({
</div>
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
{error}
</div>
) : null}

View File

@@ -201,23 +201,6 @@ export function RpgCreationResultView({
{error}
</div>
) : null}
{!error && compactAgentResultMode && previewSourceLabel ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{previewSourceLabel}
</div>
) : null}
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
{publishReady
? '当前世界已满足发布门槛。'
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
{!publishReady ? (
<div className="mt-2 text-xs text-[var(--platform-text-muted)]">
</div>
) : null}
</div>
) : null}
{!error &&
compactAgentResultMode &&
publishBlockers.length <= 0 &&

View File

@@ -1889,7 +1889,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
test('agent draft result back button syncs result profile before returning to creation hub', async () => {
const user = userEvent.setup();
vi.mocked(executeRpgCreationAction).mockResolvedValue({
@@ -2076,7 +2076,7 @@ test('agent draft result back button returns to creation hub without redundant s
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
).toBe(true);
expect(screen.queryByText('世界档案')).toBeNull();
});

View File

@@ -15,6 +15,7 @@ import {
Search,
Settings,
Sparkles,
Tags,
Ticket,
UserPlus,
UserRound,
@@ -50,7 +51,7 @@ import {
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
export type PlatformHomeTab = 'home' | 'category' | 'create' | 'saves' | 'profile';
export interface RpgEntryHomeViewProps {
activeTab: PlatformHomeTab;
onTabChange: (tab: PlatformHomeTab) => void;
@@ -96,6 +97,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
'category',
'create',
'saves',
'profile',
@@ -470,17 +472,19 @@ function PlatformTabButton({
label,
icon: Icon,
onClick,
emphasized = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
@@ -497,17 +501,19 @@ function DesktopTabButton({
label,
icon: Icon,
onClick,
emphasized = false,
}: {
active: boolean;
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
emphasized?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
>
<span className="platform-desktop-rail__icon-shell">
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
@@ -605,6 +611,41 @@ function DesktopTrendingItem({
);
}
function buildPublicCategoryGroups(
featuredEntries: CustomWorldGalleryCard[],
latestEntries: CustomWorldGalleryCard[],
) {
const publicEntryMap = new Map<string, CustomWorldGalleryCard>();
[...featuredEntries, ...latestEntries].forEach((entry) => {
publicEntryMap.set(`${entry.ownerUserId}:${entry.profileId}`, entry);
});
const categoryMap = new Map<string, CustomWorldGalleryCard[]>();
Array.from(publicEntryMap.values()).forEach((entry) => {
const tags = buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean);
const normalizedTags = tags.length > 0 ? tags : ['回响'];
normalizedTags.forEach((tag) => {
const entries = categoryMap.get(tag) ?? [];
entries.push(entry);
categoryMap.set(tag, entries);
});
});
return Array.from(categoryMap.entries())
.map(([tag, entries]) => ({ tag, entries }))
.sort((left, right) => {
if (right.entries.length !== left.entries.length) {
return right.entries.length - left.entries.length;
}
return left.tag.localeCompare(right.tag, 'zh-CN');
});
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
@@ -814,12 +855,30 @@ export function RpgEntryHomeView({
}: RpgEntryHomeViewProps) {
const authUi = useAuthUi();
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const isAuthenticated = Boolean(authUi?.user);
const isDesktopLayout = usePlatformDesktopLayout();
const featuredShelf = useMemo(
() => featuredEntries.slice(0, 6),
[featuredEntries],
);
const categoryGroups = useMemo(
() => buildPublicCategoryGroups(featuredEntries, latestEntries),
[featuredEntries, latestEntries],
);
const activeCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
categoryGroups[0] ??
null;
const visibleTabs = useMemo<PlatformHomeTab[]>(
() =>
isAuthenticated
? ['home', 'category', 'create', 'saves', 'profile']
: ['home', 'create', 'category'],
[isAuthenticated],
);
const snapshotWorldName =
savedSnapshot?.gameState.customWorldProfile?.name ??
savedSnapshot?.gameState.currentScenePreset?.name ??
@@ -842,10 +901,39 @@ export function RpgEntryHomeView({
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const tabIcons = {
home: House,
category: Tags,
create: Sparkles,
saves: Archive,
profile: UserRound,
} as const;
const tabLabels = {
home: '首页',
category: '分类',
create: '创作',
saves: '存档',
profile: '我的',
} as const;
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange('home');
}
}, [activeTab, onTabChange, visibleTabs]);
useEffect(() => {
if (categoryGroups.length === 0) {
setSelectedCategoryTag(null);
return;
}
const firstCategoryGroup = categoryGroups[0];
if (
firstCategoryGroup &&
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
}
}, [categoryGroups, selectedCategoryTag]);
const openUserSurface = () => {
if (authUi?.user) {
authUi.openAccountModal();
@@ -873,6 +961,9 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
const mobileHomeContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
@@ -967,6 +1058,51 @@ export function RpgEntryHomeView({
</div>
);
const categoryContent: ReactNode = (
<div className={categoryPageClass}>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="分类" detail="按标签浏览" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : categoryGroups.length > 0 && activeCategoryGroup ? (
<>
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={group.tag}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-pill shrink-0 px-3 py-1.5 ${active ? 'platform-pill--warm' : 'platform-pill--neutral'}`}
>
{group.tag} · {group.entries.length}
</button>
);
})}
</div>
<div className="mt-4 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4">
{activeCategoryGroup.entries.map((entry) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:category:${activeCategoryGroup.tag}`}
entry={entry}
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
/>
))}
</div>
</>
) : (
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
)}
</section>
</div>
);
const createContent: ReactNode = createTabContent ?? (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<button
@@ -1554,11 +1690,14 @@ export function RpgEntryHomeView({
const tabContentById = {
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
category: categoryContent,
create: createContent,
saves: savesContent,
profile: profileContent,
} satisfies Record<PlatformHomeTab, ReactNode>;
const tabPanels = PLATFORM_HOME_TABS.map((tab) => (
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
visibleTabs.includes(tab),
).map((tab) => (
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
{tabContentById[tab]}
</PlatformTabPanel>
@@ -1582,31 +1721,19 @@ export function RpgEntryHomeView({
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div className="platform-bottom-nav grid grid-cols-4">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
icon={tabIcons.home}
onClick={() => onTabChange('home')}
/>
<PlatformTabButton
active={activeTab === 'create'}
label="创作"
icon={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<PlatformTabButton
active={activeTab === 'saves'}
label="存档"
icon={tabIcons.saves}
onClick={() => onTabChange('saves')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"
icon={tabIcons.profile}
onClick={() => onTabChange('profile')}
/>
<div
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : 'grid-cols-3'}`}
>
{visibleTabs.map((tab) => (
<PlatformTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
/>
))}
</div>
</div>
</div>
@@ -1686,30 +1813,16 @@ export function RpgEntryHomeView({
<div className="mt-5 flex min-h-0 gap-5">
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
<DesktopTabButton
active={activeTab === 'home'}
label="首页"
icon={tabIcons.home}
onClick={() => onTabChange('home')}
/>
<DesktopTabButton
active={activeTab === 'create'}
label="创作"
icon={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<DesktopTabButton
active={activeTab === 'saves'}
label="存档"
icon={tabIcons.saves}
onClick={() => onTabChange('saves')}
/>
<DesktopTabButton
active={activeTab === 'profile'}
label="我的"
icon={tabIcons.profile}
onClick={() => onTabChange('profile')}
/>
{visibleTabs.map((tab) => (
<DesktopTabButton
key={tab}
active={activeTab === tab}
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
/>
))}
</aside>
<div className="platform-tab-panel-stack min-w-0 flex-1">