Files
Genarrative/src/components/creation-home/ClientDownloadEntry.tsx
T
k88936 b43a663fe2
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m5s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m26s
Project CI / Backend tests (pull_request) Successful in 4m5s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 6m3s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m46s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m42s
Project CI / Repository checks (pull_request) Failing after 2m3s
修复移动端顶栏折行与头像裁剪弹窗被底部主菜单遮挡
- 主站顶栏在所有断点保持单行:去掉顶栏与动作区的 flex-wrap,品牌和下载、泥点、账户入口不再折成两行
- 窄屏(<640px)下载入口收起为 44×44 图标按钮,保留 aria-label/title,≥640px 恢复「下载客户端」完整文案
- 窄屏(<480px)顶栏品牌只保留 IP 标识,让出空间给下载、泥点与账户入口,并收紧窄屏动作区间距
- 头像裁剪弹窗去掉 portal={false},改由 UnifiedModal 默认 portal 渲染到 document.body
- 个人中心两级弹窗壳层同步去掉 portal={false},昵称等个人中心弹窗一并回到页面级层级
- 弹窗不再受 .platform-desktop-layout 的 z-index:1 stacking context 限制,层级高于固定底部主菜单(z-index:60)
- 共享记忆补记单行顶栏口径与「内联弹窗被固定底栏压住」的排障经验
2026-09-24 21:22:15 +08:00

177 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getPlatformActionButtonClassName } from '@genarrative/shared/components';
import { Download, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { ClientDownloadResponse } from '../../../packages/shared/src/contracts/clientDownload';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { UnifiedModal } from '../common/UnifiedModal';
const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试';
type DownloadState =
| { status: 'loading' }
| { status: 'ready'; release: ClientDownloadResponse }
| { status: 'error' };
const platformNames = { windows: 'Windows', macos: 'macOS' } as const;
function downloadLabel(download: ClientDownloadResponse['downloads'][number]) {
if (download.platform === 'windows') return 'Windows 64 位';
return `macOS(${download.architecture === 'aarch64' ? 'Apple Silicon' : 'Intel'})`;
}
export function ClientDownloadEntry() {
const [open, setOpen] = useState(false);
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<DownloadState>({ status: 'loading' });
useEffect(() => {
if (!open) return;
const controller = new AbortController();
let disposed = false;
const timeout = window.setTimeout(() => {
if (disposed) return;
disposed = true;
controller.abort();
setState({ status: 'error' });
}, 15_000);
setState({ status: 'loading' });
void (async () => {
try {
const response = await fetch('/api/client-downloads', {
signal: controller.signal,
cache: 'no-store',
credentials: 'omit',
});
if (!response.ok) throw new Error(DOWNLOAD_ERROR);
const release = (await response.json()) as ClientDownloadResponse;
if (
!Array.isArray(release.downloads) ||
!Array.isArray(release.unavailablePlatforms)
) {
throw new Error(DOWNLOAD_ERROR);
}
if (!disposed) setState({ status: 'ready', release });
} catch {
if (!disposed) setState({ status: 'error' });
} finally {
window.clearTimeout(timeout);
}
})();
return () => {
disposed = true;
window.clearTimeout(timeout);
controller.abort();
};
}, [open, attempt]);
return (
<>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
aria-label="下载客户端"
title="下载客户端"
className="min-h-11 shrink-0 gap-0 whitespace-nowrap max-sm:h-11 max-sm:w-11 max-sm:px-0 sm:gap-2 sm:px-4"
onClick={() => {
setState({ status: 'loading' });
setOpen(true);
}}
>
<Download className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="hidden sm:inline">下载客户端</span>
</PlatformActionButton>
<UnifiedModal
open={open}
onClose={() => setOpen(false)}
title="下载陶泥儿客户端"
size="sm"
>
{state.status === 'loading' ? (
<PlatformStatusMessage tone="info" role="status">
正在获取最新版本…
</PlatformStatusMessage>
) : state.status === 'error' ? (
<div className="flex flex-col gap-4">
<PlatformStatusMessage tone="error" role="alert">
{DOWNLOAD_ERROR}
</PlatformStatusMessage>
<PlatformActionButton
onClick={() => setAttempt((value) => value + 1)}
>
重试
</PlatformActionButton>
</div>
) : (
<div className="flex flex-col gap-4">
{state.release.downloads.map((download) => (
<PlatformSubpanel
key={`${download.platform}-${download.architecture}`}
as="section"
aria-label={downloadLabel(download)}
padding="md"
radius="md"
>
<div className="mb-4 flex items-center gap-3">
<Monitor
className="h-7 w-7 shrink-0 text-[var(--platform-accent-strong)]"
aria-hidden="true"
/>
<div>
<p className="font-semibold text-[var(--platform-text-strong)]">
{downloadLabel(download)}
</p>
<p className="text-sm text-[var(--platform-text-soft)]">
最新版本 v{download.version}
</p>
</div>
</div>
<a
href={download.downloadUrl}
className={getPlatformActionButtonClassName({
size: 'md',
fullWidth: true,
})}
referrerPolicy="no-referrer"
>
<Download className="h-4 w-4 shrink-0" aria-hidden="true" />
{download.platform === 'windows'
? '下载 Windows 版'
: `下载 ${downloadLabel(download)}`}
</a>
</PlatformSubpanel>
))}
{state.release.downloads.length === 0 ? (
<PlatformStatusMessage tone="info" role="status">
暂无可下载版本
</PlatformStatusMessage>
) : null}
{state.release.unavailablePlatforms.length > 0 ? (
<PlatformStatusMessage tone="warning" role="alert">
{state.release.unavailablePlatforms
.map((platform) => platformNames[platform])
.join('、')}{' '}
版本暂时无法获取
</PlatformStatusMessage>
) : null}
{state.release.downloads.length === 0 ||
state.release.unavailablePlatforms.length > 0 ? (
<PlatformActionButton
tone="secondary"
onClick={() => setAttempt((value) => value + 1)}
>
重试
</PlatformActionButton>
) : null}
</div>
)}
</UnifiedModal>
</>
);
}