feat: ship portal and android release updates
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
def buildTimestamp = new Date()
|
||||
def buildVersionCode = System.getenv('YOYUZH_ANDROID_VERSION_CODE') ?: buildTimestamp.format('yyDDDHHmm')
|
||||
def buildVersionName = System.getenv('YOYUZH_ANDROID_VERSION_NAME') ?: buildTimestamp.format('yyyy.MM.dd.HHmm')
|
||||
|
||||
android {
|
||||
namespace = "xyz.yoyuzh.portal"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
@@ -7,8 +11,8 @@ android {
|
||||
applicationId "xyz.yoyuzh.portal"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode Integer.parseInt(buildVersionCode)
|
||||
versionName buildVersionName
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -9,7 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
10
front/package-lock.json
generated
10
front/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.3.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/cli": "^8.3.0",
|
||||
"@capacitor/core": "^8.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -326,6 +327,15 @@
|
||||
"@capacitor/core": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/app": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz",
|
||||
"integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/cli": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"test": "node --import tsx --test src/**/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/android": "^8.3.0",
|
||||
"@capacitor/cli": "^8.3.0",
|
||||
"@capacitor/core": "^8.3.0",
|
||||
|
||||
38
front/src/components/layout/UploadProgressPanel.test.ts
Normal file
38
front/src/components/layout/UploadProgressPanel.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, test } from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { createUploadTask } from '@/src/pages/files-upload';
|
||||
import {
|
||||
clearFilesUploads,
|
||||
replaceFilesUploads,
|
||||
resetFilesUploadStoreForTests,
|
||||
setFilesUploadPanelOpen,
|
||||
} from '@/src/pages/files-upload-store';
|
||||
|
||||
import { UploadProgressPanel } from './UploadProgressPanel';
|
||||
|
||||
afterEach(() => {
|
||||
resetFilesUploadStoreForTests();
|
||||
});
|
||||
|
||||
test('mobile upload progress panel renders as a top summary card instead of a bottom desktop panel', () => {
|
||||
replaceFilesUploads([
|
||||
createUploadTask(new File(['demo'], 'demo.txt', { type: 'text/plain' }), []),
|
||||
]);
|
||||
setFilesUploadPanelOpen(false);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(UploadProgressPanel, {
|
||||
variant: 'mobile',
|
||||
className: 'top-offset-anchor',
|
||||
}),
|
||||
);
|
||||
|
||||
clearFilesUploads();
|
||||
|
||||
assert.match(html, /top-offset-anchor/);
|
||||
assert.match(html, /已在后台上传 1 项/);
|
||||
assert.doesNotMatch(html, /bottom-6/);
|
||||
});
|
||||
@@ -10,21 +10,84 @@ import {
|
||||
toggleFilesUploadPanelOpen,
|
||||
useFilesUploadStore,
|
||||
} from '@/src/pages/files-upload-store';
|
||||
import type { UploadTask } from '@/src/pages/files-upload';
|
||||
|
||||
export function UploadProgressPanel() {
|
||||
export type UploadProgressPanelVariant = 'desktop' | 'mobile';
|
||||
|
||||
export function getUploadProgressSummary(uploads: UploadTask[]) {
|
||||
const uploadingCount = uploads.filter((task) => task.status === 'uploading').length;
|
||||
const completedCount = uploads.filter((task) => task.status === 'completed').length;
|
||||
const errorCount = uploads.filter((task) => task.status === 'error').length;
|
||||
const cancelledCount = uploads.filter((task) => task.status === 'cancelled').length;
|
||||
const uploadingTasks = uploads.filter((task) => task.status === 'uploading');
|
||||
const activeProgress = uploadingTasks.length > 0
|
||||
? Math.round(uploadingTasks.reduce((sum, task) => sum + task.progress, 0) / uploadingTasks.length)
|
||||
: uploads.length > 0 && completedCount === uploads.length
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
if (uploadingCount > 0) {
|
||||
return {
|
||||
title: `已在后台上传 ${uploadingCount} 项`,
|
||||
detail: `${completedCount}/${uploads.length} 已完成 · ${activeProgress}%`,
|
||||
progress: activeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
return {
|
||||
title: `上传结束,${errorCount} 项失败`,
|
||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||
progress: activeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
if (cancelledCount > 0) {
|
||||
return {
|
||||
title: '上传已停止',
|
||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||
progress: activeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `上传已完成 ${completedCount} 项`,
|
||||
detail: `${completedCount}/${uploads.length} 已完成`,
|
||||
progress: activeProgress,
|
||||
};
|
||||
}
|
||||
|
||||
interface UploadProgressPanelProps {
|
||||
className?: string;
|
||||
variant?: UploadProgressPanelVariant;
|
||||
}
|
||||
|
||||
export function UploadProgressPanel({
|
||||
className,
|
||||
variant = 'desktop',
|
||||
}: UploadProgressPanelProps = {}) {
|
||||
const { uploads, isUploadPanelOpen } = useFilesUploadStore();
|
||||
|
||||
if (uploads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summary = getUploadProgressSummary(uploads);
|
||||
const isMobile = variant === 'mobile';
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
className="fixed bottom-6 right-6 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border border-white/10 bg-[#0f172a]/95 shadow-2xl backdrop-blur-xl"
|
||||
className={cn(
|
||||
'z-50 flex flex-col overflow-hidden border border-white/10 bg-[#0f172a]/95 backdrop-blur-xl',
|
||||
isMobile
|
||||
? 'w-full rounded-2xl shadow-[0_16px_40px_rgba(15,23,42,0.28)]'
|
||||
: 'fixed bottom-6 right-6 w-[min(24rem,calc(100vw-2rem))] rounded-xl shadow-2xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between border-b border-white/10 bg-white/5 px-4 py-3 transition-colors hover:bg-white/10"
|
||||
@@ -32,11 +95,21 @@ export function UploadProgressPanel() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UploadCloud className="h-4 w-4 text-[#336EFF]" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
上传进度 ({uploads.filter((task) => task.status === 'completed').length}/{uploads.length})
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{isMobile ? summary.title : `上传进度 (${uploads.filter((task) => task.status === 'completed').length}/${uploads.length})`}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<span className="text-[11px] text-slate-400">{summary.detail}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isMobile ? (
|
||||
<span className="rounded-full bg-[#336EFF]/15 px-2 py-1 text-[11px] font-medium text-[#8fb0ff]">
|
||||
{summary.progress}%
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" className="rounded p-1 text-slate-400 transition-colors hover:bg-white/10 hover:text-white">
|
||||
{isUploadPanelOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -55,7 +128,12 @@ export function UploadProgressPanel() {
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isUploadPanelOpen && (
|
||||
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} exit={{ height: 0 }} className="max-h-80 overflow-y-auto">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
className={cn(isMobile ? 'max-h-64 overflow-y-auto' : 'max-h-80 overflow-y-auto')}
|
||||
>
|
||||
<div className="space-y-1 p-2">
|
||||
{uploads.map((task) => (
|
||||
<div
|
||||
|
||||
@@ -130,6 +130,14 @@ export interface DownloadUrlResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AndroidReleaseInfo {
|
||||
downloadUrl: string;
|
||||
fileName: string;
|
||||
versionCode: string | null;
|
||||
versionName: string | null;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateFileShareLinkResponse {
|
||||
token: string;
|
||||
filename: string;
|
||||
|
||||
@@ -51,6 +51,14 @@ export function getMobileViewportOffsetClassNames(isNativeShell = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getMobileUploadPanelOffsetClassName(isNativeShell = false) {
|
||||
if (isNativeShell) {
|
||||
return 'top-[calc(3.5rem+1rem+var(--app-safe-area-top))]';
|
||||
}
|
||||
|
||||
return 'top-[calc(3.5rem+0.75rem+var(--app-safe-area-top))]';
|
||||
}
|
||||
|
||||
type ActiveModal = 'security' | 'settings' | null;
|
||||
|
||||
export function getVisibleNavItems(isAdmin: boolean) {
|
||||
@@ -66,8 +74,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||
const navItems = getVisibleNavItems(isAdmin);
|
||||
const isNativeShell = typeof window !== 'undefined' && isNativeMobileShellLocation(window.location);
|
||||
const viewportOffsets = getMobileViewportOffsetClassNames(
|
||||
typeof window !== 'undefined' && isNativeMobileShellLocation(window.location),
|
||||
isNativeShell,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -333,9 +342,9 @@ export function MobileLayout({ children }: LayoutProps = {}) {
|
||||
</main>
|
||||
|
||||
{/* Upload Panel (Floating above bottom bar) */}
|
||||
<div className="fixed bottom-20 right-4 left-4 z-40 pointer-events-none">
|
||||
<div className={cn('fixed left-3 right-3 z-40 pointer-events-none', getMobileUploadPanelOffsetClassName(isNativeShell))}>
|
||||
<div className="pointer-events-auto">
|
||||
<UploadProgressPanel />
|
||||
<UploadProgressPanel variant="mobile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
19
front/src/mobile-pages/MobileFiles.test.ts
Normal file
19
front/src/mobile-pages/MobileFiles.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
19
front/src/mobile-pages/MobileTransfer.test.ts
Normal file
19
front/src/mobile-pages/MobileTransfer.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -352,18 +352,17 @@ export default function Overview() {
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-white">下载 APK 安装包</h3>
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
|
||||
当前 Android 安装包会随前端站点一起发布到 OSS,可直接从这里下载最新版本。
|
||||
当前 Android 安装包通过独立发包链路维护,可直接从这里获取最新版本。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">稳定路径</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">OSS 托管</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">后端分发</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">独立发包</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">一键下载</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={APK_DOWNLOAD_PATH}
|
||||
download="yoyuzh-portal.apk"
|
||||
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl bg-[#336EFF] px-6 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
|
||||
>
|
||||
下载 APK
|
||||
|
||||
@@ -4,11 +4,13 @@ import { test } from 'node:test';
|
||||
import {
|
||||
APK_DOWNLOAD_PATH,
|
||||
APK_DOWNLOAD_PUBLIC_URL,
|
||||
formatApkPublishedAtLabel,
|
||||
getDesktopOverviewSectionColumns,
|
||||
getDesktopOverviewStretchSection,
|
||||
getMobileOverviewApkEntryMode,
|
||||
getOverviewLoadErrorMessage,
|
||||
getOverviewStorageQuotaLabel,
|
||||
isAndroidReleaseNewer,
|
||||
shouldShowOverviewApkDownload,
|
||||
} from './overview-state';
|
||||
|
||||
@@ -26,9 +28,9 @@ test('generic overview failures stay generic when not coming right after login',
|
||||
);
|
||||
});
|
||||
|
||||
test('overview exposes a stable apk download path for oss hosting', () => {
|
||||
assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk');
|
||||
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk');
|
||||
test('overview exposes a backend download endpoint for apk delivery', () => {
|
||||
assert.equal(APK_DOWNLOAD_PATH, 'https://api.yoyuzh.xyz/api/app/android/download');
|
||||
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://api.yoyuzh.xyz/api/app/android/download');
|
||||
});
|
||||
|
||||
test('overview hides the apk download entry inside the native app shell', () => {
|
||||
@@ -64,3 +66,30 @@ test('overview storage quota label uses the real quota instead of a fixed 50 GB
|
||||
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
|
||||
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
|
||||
});
|
||||
|
||||
test('apk published time is formatted into a readable update label', () => {
|
||||
assert.match(formatApkPublishedAtLabel('2026-04-03T08:33:54Z') ?? '', /04[/-]03 16:33/);
|
||||
assert.equal(formatApkPublishedAtLabel(null), null);
|
||||
});
|
||||
|
||||
test('android update check compares numeric versionCode first', () => {
|
||||
assert.equal(isAndroidReleaseNewer({
|
||||
currentVersionCode: '260931807',
|
||||
releaseVersionCode: '260931807',
|
||||
}), false);
|
||||
assert.equal(isAndroidReleaseNewer({
|
||||
currentVersionCode: '260931807',
|
||||
releaseVersionCode: '260931808',
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('android update check falls back to versionName comparison', () => {
|
||||
assert.equal(isAndroidReleaseNewer({
|
||||
currentVersionName: '2026.04.03.1807',
|
||||
releaseVersionName: '2026.04.03.1807',
|
||||
}), false);
|
||||
assert.equal(isAndroidReleaseNewer({
|
||||
currentVersionName: '2026.04.03.1807',
|
||||
releaseVersionName: '2026.04.03.1810',
|
||||
}), true);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,73 @@
|
||||
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
|
||||
|
||||
export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk';
|
||||
export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk';
|
||||
export const APK_DOWNLOAD_PATH = 'https://api.yoyuzh.xyz/api/app/android/download';
|
||||
export const APK_DOWNLOAD_PUBLIC_URL = 'https://api.yoyuzh.xyz/api/app/android/download';
|
||||
|
||||
function normalizeVersionParts(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.split(/[^0-9A-Za-z]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => (/^\d+$/.test(part) ? Number(part) : part.toLowerCase()));
|
||||
}
|
||||
|
||||
function compareVersionParts(left: Array<number | string>, right: Array<number | string>) {
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftPart = left[index] ?? 0;
|
||||
const rightPart = right[index] ?? 0;
|
||||
|
||||
if (leftPart === rightPart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
|
||||
return leftPart > rightPart ? 1 : -1;
|
||||
}
|
||||
|
||||
return String(leftPart).localeCompare(String(rightPart), 'en');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isAndroidReleaseNewer({
|
||||
currentVersionCode,
|
||||
currentVersionName,
|
||||
releaseVersionCode,
|
||||
releaseVersionName,
|
||||
}: {
|
||||
currentVersionCode?: string | null;
|
||||
currentVersionName?: string | null;
|
||||
releaseVersionCode?: string | null;
|
||||
releaseVersionName?: string | null;
|
||||
}) {
|
||||
if (currentVersionCode && releaseVersionCode && /^\d+$/.test(currentVersionCode) && /^\d+$/.test(releaseVersionCode)) {
|
||||
return BigInt(releaseVersionCode) > BigInt(currentVersionCode);
|
||||
}
|
||||
|
||||
if (currentVersionName && releaseVersionName) {
|
||||
return compareVersionParts(normalizeVersionParts(currentVersionName), normalizeVersionParts(releaseVersionName)) < 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatApkPublishedAtLabel(publishedAt: string | null) {
|
||||
if (!publishedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(publishedAt));
|
||||
}
|
||||
|
||||
function formatOverviewStorageSize(size: number) {
|
||||
if (size <= 0) {
|
||||
|
||||
Reference in New Issue
Block a user