feat: ship portal and android release updates

This commit is contained in:
yoyuzh
2026-04-05 13:57:13 +08:00
parent 52b5bbfe8e
commit ed837f5ec9
46 changed files with 1507 additions and 189 deletions

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getMobileFilesLayoutClassNames } from './MobileFiles';
test('mobile files uses a single page scroller and keeps the toolbar sticky', () => {
const classNames = getMobileFilesLayoutClassNames();
assert.match(classNames.root, /\bmin-h-full\b/);
assert.match(classNames.root, /\bbg-transparent\b/);
assert.doesNotMatch(classNames.root, /\boverflow-hidden\b/);
assert.match(classNames.toolbar, /\bsticky\b/);
assert.match(classNames.toolbar, /\btop-0\b/);
assert.match(classNames.toolbar, /\bpy-2\b/);
assert.match(classNames.toolbarInner, /\bglass-panel\b/);
assert.match(classNames.list, /\bpt-2\b/);
assert.match(classNames.list, /\bpb-4\b/);
assert.doesNotMatch(classNames.list, /\boverflow-y-auto\b/);
});

View File

@@ -121,6 +121,15 @@ interface UiFile {
type NetdiskTargetAction = 'move' | 'copy';
export function getMobileFilesLayoutClassNames() {
return {
root: 'relative flex min-h-full flex-col text-white bg-transparent',
toolbar: 'sticky top-0 z-30 flex-none px-4 py-2',
toolbarInner: 'glass-panel flex items-center gap-3 rounded-[22px] border border-white/10 bg-[#0f172a]/72 px-3.5 py-2.5 shadow-md backdrop-blur-2xl',
list: 'relative z-10 flex-1 px-3 pt-2 pb-4 space-y-1.5',
};
}
export default function MobileFiles() {
const navigate = useNavigate();
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
@@ -152,6 +161,7 @@ export default function MobileFiles() {
// Floating Action Button
const [fabOpen, setFabOpen] = useState(false);
const layoutClassNames = getMobileFilesLayoutClassNames();
const loadCurrentPath = async (pathParts: string[]) => {
const response = await apiRequest<PageResponse<FileMetadata>>(
@@ -437,7 +447,7 @@ export default function MobileFiles() {
};
return (
<div className="flex flex-col h-[calc(100vh-3.5rem)] relative overflow-hidden text-white bg-[#07101D]">
<div className={layoutClassNames.root}>
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-12%] left-[-24%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[22%] right-[-20%] h-80 w-80 rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[100px] animate-blob animation-delay-2000" />
@@ -448,8 +458,8 @@ export default function MobileFiles() {
<input type="file" ref={directoryInputRef} className="hidden" onChange={handleFolderChange} />
{/* Top Header - Path navigation */}
<div className="flex-none px-4 py-3 bg-[#0f172a]/80 border-b border-white/5 sticky top-0 z-20 shadow-md backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className={layoutClassNames.toolbar}>
<div className={layoutClassNames.toolbarInner}>
<div className="flex min-w-0 flex-1 flex-nowrap items-center text-sm overflow-x-auto custom-scrollbar whitespace-nowrap">
{currentPath.length > 0 && (
<button className="mr-3 p-1.5 rounded-full bg-white/5 text-slate-300 active:bg-white/10" onClick={handleBackClick}>
@@ -476,7 +486,7 @@ export default function MobileFiles() {
</div>
{/* File List */}
<div className="relative z-10 flex-1 overflow-y-auto px-3 py-2 space-y-1.5 pb-24">
<div className={layoutClassNames.list}>
{currentFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-500">
<FolderPlus className="w-10 h-10 mb-3 opacity-20" />

View File

@@ -24,14 +24,15 @@ import { readCachedValue, writeCachedValue } from '@/src/lib/cache';
import { resolveStoredFileType } from '@/src/lib/file-type';
import { getOverviewCacheKey } from '@/src/lib/page-cache';
import { clearPostLoginPending, hasPostLoginPending, readStoredSession } from '@/src/lib/session';
import type { FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
import type { AndroidReleaseInfo, FileMetadata, PageResponse, UserProfile } from '@/src/lib/types';
import {
APK_DOWNLOAD_PUBLIC_URL,
APK_DOWNLOAD_PATH,
formatApkPublishedAtLabel,
getMobileOverviewApkEntryMode,
getOverviewLoadErrorMessage,
getOverviewStorageQuotaLabel,
isAndroidReleaseNewer,
shouldShowOverviewApkDownload,
} from '@/src/pages/overview-state';
@@ -50,6 +51,22 @@ function formatRecentTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(date);
}
async function getInstalledAndroidAppVersion() {
try {
const { App } = await import('@capacitor/app');
const info = await App.getInfo();
return {
versionName: info.version ?? null,
versionCode: info.build ?? null,
};
} catch {
return {
versionName: null,
versionCode: null,
};
}
}
export default function MobileOverview() {
const navigate = useNavigate();
const cachedOverview = readCachedValue<{
@@ -90,34 +107,49 @@ export default function MobileOverview() {
setApkActionMessage('');
try {
const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, {
method: 'HEAD',
cache: 'no-store',
const [release, installedVersion] = await Promise.all([
apiRequest<AndroidReleaseInfo>('/app/android/latest', {
method: 'GET',
}),
getInstalledAndroidAppVersion(),
]);
const hasNewerRelease = isAndroidReleaseNewer({
currentVersionCode: installedVersion.versionCode,
currentVersionName: installedVersion.versionName,
releaseVersionCode: release.versionCode,
releaseVersionName: release.versionName,
});
if (!response.ok) {
throw new Error(`检查更新失败 (${response.status})`);
if (!hasNewerRelease) {
setApkActionMessage(
installedVersion.versionName
? `当前已是最新版 ${installedVersion.versionName}`
: '当前已是最新版'
);
return;
}
const lastModified = response.headers.get('last-modified');
const downloadUrl = release.downloadUrl;
const publishedAtLabel = formatApkPublishedAtLabel(release.publishedAt);
setApkActionMessage(
lastModified
? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(lastModified))},正在打开下载链接。`
: '发现最新安装包,正在打开下载链接。'
publishedAtLabel
? `发现新版本 ${release.versionName ?? ''},更新时间 ${publishedAtLabel},正在打开下载链接。`
: `发现新版本 ${release.versionName ?? ''},正在打开下载链接。`
);
if (typeof window !== 'undefined') {
const openedWindow = window.open(APK_DOWNLOAD_PUBLIC_URL, '_blank', 'noopener,noreferrer');
const openedWindow = window.open(downloadUrl, '_blank', 'noopener,noreferrer');
if (!openedWindow) {
window.location.href = APK_DOWNLOAD_PUBLIC_URL;
window.location.href = downloadUrl;
}
}
} catch (error) {
setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试');
setApkActionMessage(
error instanceof Error && error.message
? `更新服务暂时不可用:${error.message}`
: '更新服务暂时不可用,请稍后重试'
);
} finally {
setCheckingApkUpdate(false);
}
@@ -232,48 +264,6 @@ export default function MobileOverview() {
</CardContent>
</Card>
{showApkDownload || apkEntryMode === 'update' ? (
<Card className="glass-panel overflow-hidden border-[#336EFF]/20 relative">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.2),transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.94),rgba(15,23,42,0.88))]" />
<CardContent className="relative z-10 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#336EFF]/15 text-[#7ea4ff]">
<Smartphone className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-white">Android </p>
<p className="text-[11px] leading-5 text-slate-300">
{apkEntryMode === 'update'
? '在 App 内检查 OSS 上的最新安装包,并跳转到更新下载链接。'
: '总览页可直接下载最新 APK安装包与前端站点一起托管在 OSS。'}
</p>
</div>
</div>
{apkEntryMode === 'update' ? (
<button
type="button"
onClick={() => void handleCheckApkUpdate()}
disabled={checkingApkUpdate}
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc] disabled:cursor-not-allowed disabled:opacity-70"
>
{checkingApkUpdate ? '检查中...' : '检查更新'}
</button>
) : (
<a
href={APK_DOWNLOAD_PATH}
download="yoyuzh-portal.apk"
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
>
APK
</a>
)}
{apkActionMessage ? (
<p className="text-[11px] leading-5 text-slate-300">{apkActionMessage}</p>
) : null}
</CardContent>
</Card>
) : null}
{/* 近期文件 (精简版) */}
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between py-3 px-4 pb-2 border-b border-white/5">
@@ -318,6 +308,47 @@ export default function MobileOverview() {
<ChevronRight className="h-5 w-5 text-cyan-400 opacity-70" />
</CardContent>
</Card>
{showApkDownload || apkEntryMode === 'update' ? (
<Card className="glass-panel overflow-hidden border-[#336EFF]/20 relative">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.2),transparent_45%),linear-gradient(180deg,rgba(16,24,40,0.94),rgba(15,23,42,0.88))]" />
<CardContent className="relative z-10 p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-[#336EFF]/15 text-[#7ea4ff]">
<Smartphone className="h-4 w-4" />
</div>
<div className="space-y-1">
<p className="text-sm font-semibold text-white">Android </p>
<p className="text-[11px] leading-5 text-slate-300">
{apkEntryMode === 'update'
? '在 App 内检查最新安装包,并跳转到当前版本的下载地址。'
: '总览页可直接下载最新 APK安装包通过独立发包链路提供。'}
</p>
</div>
</div>
{apkEntryMode === 'update' ? (
<button
type="button"
onClick={() => void handleCheckApkUpdate()}
disabled={checkingApkUpdate}
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc] disabled:cursor-not-allowed disabled:opacity-70"
>
{checkingApkUpdate ? '检查中...' : '检查更新'}
</button>
) : (
<a
href={APK_DOWNLOAD_PATH}
className="inline-flex h-10 w-full items-center justify-center rounded-xl bg-[#336EFF] px-4 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
>
APK
</a>
)}
{apkActionMessage ? (
<p className="text-[11px] leading-5 text-slate-300">{apkActionMessage}</p>
) : null}
</CardContent>
</Card>
) : null}
{/* 留出底部边距给导航栏 */}
<div className="h-6" />

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getMobileTransferLayoutClassNames } from './MobileTransfer';
test('mobile transfer keeps its header sticky and avoids nested file-list scrolling', () => {
const classNames = getMobileTransferLayoutClassNames();
assert.match(classNames.root, /\bmin-h-full\b/);
assert.match(classNames.root, /\bbg-transparent\b/);
assert.doesNotMatch(classNames.root, /\boverflow-hidden\b/);
assert.match(classNames.header, /\bsticky\b/);
assert.match(classNames.header, /\btop-0\b/);
assert.match(classNames.header, /\bpy-2\b/);
assert.match(classNames.headerPanel, /\bglass-panel\b/);
assert.match(classNames.titlePanel, /\brelative\b/);
assert.match(classNames.content, /\bpb-6\b/);
assert.doesNotMatch(classNames.sendFileList, /\boverflow-y-auto\b/);
});

View File

@@ -94,6 +94,17 @@ function getPhaseMessage(mode: TransferMode, phase: SendPhase, errorMessage: str
}
}
export function getMobileTransferLayoutClassNames() {
return {
root: 'relative flex min-h-full flex-col bg-transparent',
header: 'sticky top-0 z-30 px-4 py-2',
headerPanel: 'glass-panel relative overflow-hidden rounded-[24px] border border-white/12 bg-[#0b1528]/82 px-3.5 py-3 shadow-[0_14px_36px_rgba(8,15,30,0.32)] backdrop-blur-2xl',
titlePanel: 'relative overflow-hidden rounded-[18px] px-3.5 pt-3 pb-3',
content: 'relative z-10 flex-1 flex flex-col min-w-0 px-4 pt-3 pb-6',
sendFileList: 'glass-panel rounded-2xl p-2.5',
};
}
export default function MobileTransfer() {
const navigate = useNavigate();
const { ready: authReady, session: authSession } = useAuth();
@@ -116,6 +127,7 @@ export default function MobileTransfer() {
const [offlineHistoryError, setOfflineHistoryError] = useState('');
const [selectedOfflineSession, setSelectedOfflineSession] = useState<TransferSessionResponse | null>(null);
const [historyCopiedSessionId, setHistoryCopiedSessionId] = useState<string | null>(null);
const layoutClassNames = getMobileTransferLayoutClassNames();
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -406,7 +418,7 @@ export default function MobileTransfer() {
}
return (
<div className="relative flex flex-col min-h-full overflow-hidden bg-[#07101D]">
<div className={layoutClassNames.root}>
<div className="pointer-events-none absolute inset-0 z-0">
<div className="absolute top-[-18%] left-[-22%] h-72 w-72 rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[100px] animate-blob" />
<div className="absolute top-[10%] right-[-18%] h-80 w-80 rounded-full bg-cyan-500 opacity-16 mix-blend-screen blur-[96px] animate-blob animation-delay-2000" />
@@ -416,35 +428,44 @@ export default function MobileTransfer() {
<input type="file" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
<input type="file" multiple className="hidden" ref={folderInputRef} onChange={handleFileSelect} />
{/* 顶部标题区 */}
<div className="relative z-10 overflow-hidden bg-[url('/noise.png')] px-5 pt-8 pb-4">
<div className="absolute top-[-50%] right-[-10%] h-[150%] w-[120%] rounded-full bg-[#336EFF] opacity-15 mix-blend-screen blur-[80px]" />
<div className="relative z-10 font-bold text-2xl tracking-wide flex items-center">
<Send className="mr-3 w-6 h-6 text-cyan-400" />
<div className={layoutClassNames.header}>
<div className={layoutClassNames.headerPanel}>
<div className="absolute inset-0 bg-[#0b1528]/64 backdrop-blur-2xl" />
<div className="absolute inset-0 bg-[url('/noise.png')] opacity-30" />
<div className="absolute inset-x-0 top-0 h-px bg-white/18" />
<div className="absolute top-[-40%] right-[-8%] h-[140%] w-[95%] rounded-full bg-[#336EFF] opacity-14 mix-blend-screen blur-[80px]" />
<div className="absolute bottom-[-65%] left-[-8%] h-[120%] w-[55%] rounded-full bg-cyan-400/10 mix-blend-screen blur-[72px]" />
<div className={layoutClassNames.titlePanel}>
<div className="relative z-10 flex items-center text-[1.375rem] font-bold tracking-wide">
<Send className="mr-2.5 h-5.5 w-5.5 text-cyan-400" />
</div>
</div>
{allowSend && (
<div className="relative z-10 mt-2.5 flex overflow-hidden rounded-2xl border border-white/8 bg-black/18">
<button
onClick={() => setActiveTab('send')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<UploadCloud className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<DownloadCloud className="w-4 h-4" />
</button>
</div>
)}
<div className="pointer-events-none absolute inset-x-3 bottom-0 h-px bg-white/8" />
</div>
</div>
{allowSend && (
<div className="relative z-10 flex bg-[#0f172a] shadow-md border-b border-white/5 mx-4 mt-2 rounded-2xl overflow-hidden glass-panel shrink-0">
<button
onClick={() => setActiveTab('send')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'send' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<UploadCloud className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('receive')}
className={cn('flex-1 py-3.5 text-sm font-medium transition-colors relative flex items-center justify-center gap-2',
activeTab === 'receive' ? 'text-white bg-blue-500/10' : 'text-slate-400')}
>
<DownloadCloud className="w-4 h-4" />
</button>
</div>
)}
<div className="relative z-10 flex-1 flex flex-col p-4 min-w-0 pb-24">
<div className={layoutClassNames.content}>
{authReady && !isAuthenticated && (
<div className="mb-4 flex flex-col gap-2 rounded-xl bg-blue-500/10 px-4 py-3 text-xs text-blue-100/90 border border-blue-400/10">
<p className="leading-relaxed">线线线线线</p>
@@ -520,7 +541,7 @@ export default function MobileTransfer() {
</div>
{/* 文件列表 */}
<div className="glass-panel rounded-2xl p-2.5 max-h-[40vh] overflow-y-auto">
<div className={layoutClassNames.sendFileList}>
<p className="text-xs text-slate-500 mb-2 px-2.5 pt-2"> {selectedFiles.length} / {formatTransferSize(totalSize)}</p>
{selectedFiles.map((f, i) => (
<div key={i} className="flex px-2.5 py-2 items-center gap-3 bg-white/[0.03] rounded-xl mb-1 hover:bg-white/5 active:bg-white/10 transition-colors">