Enable dual-device login and mobile APK update checks

This commit is contained in:
yoyuzh
2026-04-03 16:28:09 +08:00
parent 56f2a9fe0d
commit 52b5bbfe8e
50 changed files with 1659 additions and 164 deletions

View File

@@ -5,6 +5,7 @@ import { useAuth } from './auth/AuthProvider';
import Login from './pages/Login';
import Overview from './pages/Overview';
import Files from './pages/Files';
import RecycleBin from './pages/RecycleBin';
import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import Games from './pages/Games';
@@ -58,6 +59,7 @@ function AppRoutes() {
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} />
<Route path="recycle-bin" element={<RecycleBin />} />
<Route path="games" element={<Games />} />
<Route path="games/:gameId" element={<GamePlayer />} />
</Route>

View File

@@ -11,6 +11,7 @@ import MobileOverview from './mobile-pages/MobileOverview';
import MobileFiles from './mobile-pages/MobileFiles';
import MobileTransfer from './mobile-pages/MobileTransfer';
import MobileFileShare from './mobile-pages/MobileFileShare';
import RecycleBin from './pages/RecycleBin';
function LegacyTransferRedirect() {
const location = useLocation();
@@ -53,6 +54,7 @@ function MobileAppRoutes() {
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<MobileOverview />} />
<Route path="files" element={<MobileFiles />} />
<Route path="recycle-bin" element={<RecycleBin />} />
<Route path="games" element={<Navigate to="/overview" replace />} />
</Route>

View File

@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { Card } from './card';
test('Card applies the shared elevated shadow styling', () => {
const html = renderToStaticMarkup(<Card>demo</Card>);
assert.match(html, /shadow-\[0_12px_32px_rgba\(15,23,42,0\.18\)\]/);
});

View File

@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"glass-panel rounded-2xl text-white shadow-sm",
"glass-panel rounded-2xl text-white shadow-[0_12px_32px_rgba(15,23,42,0.18)]",
className
)}
{...props}

View File

@@ -13,10 +13,10 @@
--color-text-secondary: #94A3B8; /* slate-400 */
--color-text-tertiary: rgba(255, 255, 255, 0.3);
--color-glass-bg: rgba(255, 255, 255, 0.03);
--color-glass-border: rgba(255, 255, 255, 0.08);
--color-glass-hover: rgba(255, 255, 255, 0.06);
--color-glass-active: rgba(255, 255, 255, 0.1);
--color-glass-bg: rgba(255, 255, 255, 0.045);
--color-glass-border: rgba(255, 255, 255, 0.1);
--color-glass-hover: rgba(255, 255, 255, 0.07);
--color-glass-active: rgba(255, 255, 255, 0.11);
}
:root {

View File

@@ -182,6 +182,7 @@ test('apiRequest attaches bearer token and unwraps response payload', async () =
assert.deepEqual(payload, {ok: true});
assert.ok(request instanceof Request);
assert.equal(request.headers.get('Authorization'), 'Bearer token-123');
assert.equal(request.headers.get('X-Yoyuzh-Client'), 'desktop');
assert.equal(request.url, 'http://localhost/api/files/recent');
});

View File

@@ -1,4 +1,5 @@
import type { AuthResponse } from './types';
import { PORTAL_CLIENT_HEADER, resolvePortalClientType } from './app-shell';
import { clearStoredSession, createSession, readStoredSession, saveStoredSession } from './session';
interface ApiEnvelope<T> {
@@ -148,6 +149,10 @@ function normalizePath(path: string) {
return path.startsWith('/') ? path : `/${path}`;
}
function shouldAttachPortalClientHeader(path: string) {
return !/^https?:\/\//.test(path);
}
function shouldAttemptTokenRefresh(path: string) {
const normalizedPath = normalizePath(path);
return ![
@@ -189,12 +194,15 @@ async function refreshAccessToken() {
refreshRequestPromise = (async () => {
try {
const headers = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
const response = await fetch(resolveUrl(AUTH_REFRESH_PATH), {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
refreshToken: currentSession.refreshToken,
}),
@@ -269,6 +277,9 @@ async function performRequest(path: string, init: ApiRequestInit = {}, allowRefr
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
}
if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
@@ -341,6 +352,9 @@ function apiUploadRequestInternal<T>(path: string, init: ApiUploadRequestInit, a
if (session?.token) {
headers.set('Authorization', `Bearer ${session.token}`);
}
if (shouldAttachPortalClientHeader(path) && !headers.has(PORTAL_CLIENT_HEADER)) {
headers.set(PORTAL_CLIENT_HEADER, resolvePortalClientType());
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}

View File

@@ -1,10 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOBILE_APP_MAX_WIDTH, shouldUseMobileApp } from './app-shell';
import {
MOBILE_APP_MAX_WIDTH,
isNativeAppShellLocation,
resolvePortalClientType,
shouldUseMobileApp,
} from './app-shell';
test('shouldUseMobileApp enables the mobile shell below the width breakpoint', () => {
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH - 1), true);
assert.equal(shouldUseMobileApp(MOBILE_APP_MAX_WIDTH), false);
assert.equal(shouldUseMobileApp(1280), false);
});
test('isNativeAppShellLocation matches Capacitor localhost origins', () => {
assert.equal(isNativeAppShellLocation(new URL('https://localhost')), true);
assert.equal(isNativeAppShellLocation(new URL('http://127.0.0.1')), true);
assert.equal(isNativeAppShellLocation(new URL('capacitor://localhost')), true);
assert.equal(isNativeAppShellLocation(new URL('http://localhost:3000')), false);
assert.equal(isNativeAppShellLocation(new URL('https://yoyuzh.xyz')), false);
});
test('resolvePortalClientType distinguishes desktop web from mobile shell or narrow screens', () => {
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 1280 }), 'desktop');
assert.equal(resolvePortalClientType({ location: new URL('https://yoyuzh.xyz'), viewportWidth: 390 }), 'mobile');
assert.equal(resolvePortalClientType({ location: new URL('https://localhost') }), 'mobile');
});

View File

@@ -1,5 +1,69 @@
export const MOBILE_APP_MAX_WIDTH = 768;
export const PORTAL_CLIENT_HEADER = 'X-Yoyuzh-Client';
export type PortalClientType = 'desktop' | 'mobile';
export function shouldUseMobileApp(width: number) {
return width < MOBILE_APP_MAX_WIDTH;
}
export function isNativeAppShellLocation(location: Location | URL | null) {
if (!location) {
return false;
}
const hostname = location.hostname || '';
const protocol = location.protocol || '';
const port = location.port || '';
if (protocol === 'capacitor:') {
return true;
}
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
const isCapacitorLocalScheme = protocol === 'http:' || protocol === 'https:';
return isLocalhostHost && isCapacitorLocalScheme && port === '';
}
function resolveRuntimeViewportWidth() {
if (typeof globalThis.innerWidth === 'number' && Number.isFinite(globalThis.innerWidth)) {
return globalThis.innerWidth;
}
if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
return window.innerWidth;
}
return null;
}
function resolveRuntimeLocation() {
if (typeof globalThis.location !== 'undefined') {
return globalThis.location;
}
if (typeof window !== 'undefined') {
return window.location;
}
return null;
}
export function resolvePortalClientType({
location = resolveRuntimeLocation(),
viewportWidth = resolveRuntimeViewportWidth(),
}: {
location?: Location | URL | null;
viewportWidth?: number | null;
} = {}): PortalClientType {
if (isNativeAppShellLocation(location)) {
return 'mobile';
}
if (typeof viewportWidth === 'number' && shouldUseMobileApp(viewportWidth)) {
return 'mobile';
}
return 'desktop';
}

View File

@@ -106,6 +106,18 @@ export interface FileMetadata {
createdAt: string;
}
export interface RecycleBinItem {
id: number;
filename: string;
path: string;
size: number;
contentType: string | null;
directory: boolean;
createdAt: string;
deletedAt: string;
expiresAt: string;
}
export interface InitiateUploadResponse {
direct: boolean;
uploadUrl: string;

View File

@@ -17,6 +17,7 @@ import { AnimatePresence, motion } from 'motion/react';
import { useAuth } from '@/src/auth/AuthProvider';
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
import { cn } from '@/src/lib/utils';
@@ -33,16 +34,7 @@ const NAV_ITEMS = [
] as const;
export function isNativeMobileShellLocation(location: Location | URL | null) {
if (!location) {
return false;
}
const hostname = location.hostname || '';
const protocol = location.protocol || '';
const isLocalhostHost = hostname === 'localhost' || hostname === '127.0.0.1';
const isCapacitorScheme = protocol === 'http:' || protocol === 'https:' || protocol === 'capacitor:';
return isLocalhostHost && isCapacitorScheme;
return isNativeAppShellLocation(location);
}
export function getMobileViewportOffsetClassNames(isNativeShell = false) {

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import {
ChevronRight,
Folder,
@@ -13,7 +14,8 @@ import {
Edit2,
Trash2,
FolderPlus,
ChevronLeft
ChevronLeft,
RotateCcw,
} from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
@@ -66,6 +68,7 @@ import {
import {
toDirectoryPath,
} from '@/src/pages/files-tree';
import { RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from '@/src/pages/recycle-bin-state';
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -119,6 +122,7 @@ interface UiFile {
type NetdiskTargetAction = 'move' | 'copy';
export default function MobileFiles() {
const navigate = useNavigate();
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
@@ -445,19 +449,29 @@ export default function MobileFiles() {
{/* 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 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}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
<div className="flex items-center gap-3">
<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}>
<ChevronLeft className="w-4 h-4" />
</button>
)}
<button className="text-slate-400 hover:text-white" onClick={() => handleBreadcrumbClick(-1)}></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-3 h-3 mx-1 text-slate-600 shrink-0" />
<button onClick={() => handleBreadcrumbClick(index)} className={cn(index === currentPath.length - 1 ? 'text-white font-medium' : 'text-slate-400', 'shrink-0')}>{pathItem}</button>
</React.Fragment>
))}
</div>
<button
type="button"
onClick={() => navigate(RECYCLE_BIN_ROUTE)}
className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-slate-200"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</div>
@@ -584,10 +598,10 @@ export default function MobileFiles() {
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setDeleteModalOpen(false)} />
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="relative w-full max-w-sm glass-panel bg-[#0f172a] border border-white/10 rounded-2xl p-5 z-10 shadow-2xl">
<h3 className="text-lg font-bold text-white mb-2 flex items-center gap-2"><Trash2 className="text-red-400 w-5 h-5"/></h3>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> </p>
<p className="text-sm text-slate-300 mb-6 mt-3"> <span className="text-white font-medium break-all">{fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS} </p>
<div className="flex gap-3">
<Button variant="outline" className="flex-1 bg-white/5 border-white/10 text-white" onClick={() => setDeleteModalOpen(false)}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
<Button className="flex-1 bg-red-500 text-white hover:bg-red-600" onClick={handleDelete}></Button>
</div>
</motion.div>
</div>

View File

@@ -9,6 +9,7 @@ import {
FolderPlus,
Mail,
Send,
Smartphone,
Upload,
User,
Zap,
@@ -25,7 +26,14 @@ 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 { getOverviewLoadErrorMessage } from '@/src/pages/overview-state';
import {
APK_DOWNLOAD_PUBLIC_URL,
APK_DOWNLOAD_PATH,
getMobileOverviewApkEntryMode,
getOverviewLoadErrorMessage,
getOverviewStorageQuotaLabel,
shouldShowOverviewApkDownload,
} from '@/src/pages/overview-state';
function formatFileSize(size: number) {
if (size <= 0) return '0 B';
@@ -56,6 +64,8 @@ export default function MobileOverview() {
const [loadingError, setLoadingError] = useState('');
const [retryToken, setRetryToken] = useState(0);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [apkActionMessage, setApkActionMessage] = useState('');
const [checkingApkUpdate, setCheckingApkUpdate] = useState(false);
const currentHour = new Date().getHours();
let greeting = '晚上好';
@@ -72,6 +82,46 @@ export default function MobileOverview() {
const latestFile = recentFiles[0] ?? null;
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
const showApkDownload = shouldShowOverviewApkDownload();
const apkEntryMode = getMobileOverviewApkEntryMode();
const handleCheckApkUpdate = async () => {
setCheckingApkUpdate(true);
setApkActionMessage('');
try {
const response = await fetch(APK_DOWNLOAD_PUBLIC_URL, {
method: 'HEAD',
cache: 'no-store',
});
if (!response.ok) {
throw new Error(`检查更新失败 (${response.status})`);
}
const lastModified = response.headers.get('last-modified');
setApkActionMessage(
lastModified
? `发现最新安装包,更新时间 ${new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(lastModified))},正在打开下载链接。`
: '发现最新安装包,正在打开下载链接。'
);
if (typeof window !== 'undefined') {
const openedWindow = window.open(APK_DOWNLOAD_PUBLIC_URL, '_blank', 'noopener,noreferrer');
if (!openedWindow) {
window.location.href = APK_DOWNLOAD_PUBLIC_URL;
}
}
} catch (error) {
setApkActionMessage(error instanceof Error ? error.message : '检查更新失败,请稍后重试');
} finally {
setCheckingApkUpdate(false);
}
};
useEffect(() => {
let cancelled = false;
@@ -166,7 +216,7 @@ export default function MobileOverview() {
<MobileMetricCard title="文件总数" value={`${rootFiles.length}`} icon={FileText} delay={0.1} color="text-amber-400" bg="bg-amber-500/20" />
<MobileMetricCard title="近期上传" value={`${recentWeekUploads}`} icon={Upload} delay={0.15} color="text-emerald-400" bg="bg-emerald-500/20" />
<MobileMetricCard title="快传就绪" value={latestFile ? '使用中' : '待命'} icon={Send} delay={0.2} color="text-[#336EFF]" bg="bg-[#336EFF]/20" />
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={`${formatFileSize(usedBytes)}`} />
<MobileMetricCard title="存储占用" value={`${storagePercent.toFixed(1)}%`} icon={Database} delay={0.25} color="text-purple-400" bg="bg-purple-500/20" subtitle={getOverviewStorageQuotaLabel(storageQuotaBytes)} />
</div>
{/* 快捷操作区 */}
@@ -182,6 +232,48 @@ 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">

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useLocation, useNavigate } from 'react-router-dom';
import {
ChevronDown,
Folder,
@@ -16,6 +17,7 @@ import {
X,
Edit2,
Trash2,
RotateCcw,
} from 'lucide-react';
import { NetdiskPathPickerModal } from '@/src/components/ui/NetdiskPathPickerModal';
@@ -74,6 +76,7 @@ import {
type DirectoryChildrenMap,
type DirectoryTreeNode,
} from './files-tree';
import { getFilesSidebarFooterEntries, RECYCLE_BIN_RETENTION_DAYS, RECYCLE_BIN_ROUTE } from './recycle-bin-state';
function sleep(ms: number) {
return new Promise((resolve) => {
@@ -180,6 +183,8 @@ interface UiFile {
type NetdiskTargetAction = 'move' | 'copy';
export default function Files() {
const navigate = useNavigate();
const location = useLocation();
const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -752,11 +757,11 @@ export default function Files() {
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */}
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
<CardContent className="p-4">
<div className="space-y-2">
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-hidden">
<CardContent className="flex h-full flex-col p-4">
<div className="min-h-0 flex-1 space-y-2">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider"></p>
<div className="rounded-2xl border border-white/5 bg-black/20 p-2">
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-white/5 bg-black/20 p-2">
<button
type="button"
onClick={() => handleSidebarClick([])}
@@ -768,7 +773,7 @@ export default function Files() {
<Folder className={cn('h-4 w-4', currentPath.length === 0 ? 'text-[#336EFF]' : 'text-slate-500')} />
<span className="truncate"></span>
</button>
<div className="mt-1 space-y-0.5">
<div className="mt-1 min-h-0 flex-1 space-y-0.5 overflow-y-auto pr-1">
{directoryTree.map((node) => (
<DirectoryTreeItem
key={node.id}
@@ -780,6 +785,30 @@ export default function Files() {
</div>
</div>
</div>
<div className="mt-4 border-t border-white/10 pt-4">
{getFilesSidebarFooterEntries().map((entry) => {
const isActive = location.pathname === entry.path || location.pathname === RECYCLE_BIN_ROUTE;
return (
<button
key={entry.path}
type="button"
onClick={() => navigate(entry.path)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left text-sm transition-colors',
isActive
? 'border-[#336EFF]/30 bg-[#336EFF]/15 text-[#7ea6ff]'
: 'border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',
)}
>
<RotateCcw className={cn('h-4 w-4', isActive ? 'text-[#7ea6ff]' : 'text-slate-400')} />
<div className="min-w-0">
<p className="font-medium">{entry.label}</p>
<p className="truncate text-xs text-slate-500"> {RECYCLE_BIN_RETENTION_DAYS} </p>
</div>
</button>
);
})}
</div>
</CardContent>
</Card>
@@ -1136,7 +1165,7 @@ export default function Files() {
</div>
<div className="space-y-5 p-5">
<p className="text-sm leading-relaxed text-slate-300">
<span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span>
<span className="rounded bg-white/10 px-1 py-0.5 font-medium text-white">{fileToDelete?.name}</span> {RECYCLE_BIN_RETENTION_DAYS}
</p>
<div className="flex justify-end gap-3 pt-2">
<Button
@@ -1154,7 +1183,7 @@ export default function Files() {
className="border-red-500/30 bg-red-500 text-white hover:bg-red-600"
onClick={() => void handleDelete()}
>
</Button>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import {
FolderPlus,
Mail,
Send,
Smartphone,
Upload,
User,
Zap,
@@ -25,7 +26,14 @@ 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 { getOverviewLoadErrorMessage } from './overview-state';
import {
APK_DOWNLOAD_PATH,
getDesktopOverviewSectionColumns,
getDesktopOverviewStretchSection,
getOverviewLoadErrorMessage,
getOverviewStorageQuotaLabel,
shouldShowOverviewApkDownload,
} from './overview-state';
function formatFileSize(size: number) {
if (size <= 0) {
@@ -89,6 +97,9 @@ export default function Overview() {
const latestFile = recentFiles[0] ?? null;
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
const showApkDownload = shouldShowOverviewApkDownload();
const desktopSections = getDesktopOverviewSectionColumns(showApkDownload);
const desktopStretchSection = getDesktopOverviewStretchSection(showApkDownload);
useEffect(() => {
let cancelled = false;
@@ -242,8 +253,8 @@ export default function Overview() {
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="grid grid-cols-1 items-stretch lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 flex flex-col gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle></CardTitle>
@@ -297,9 +308,9 @@ export default function Overview() {
</CardContent>
</Card>
<Card className="overflow-hidden">
<Card className={desktopStretchSection === 'transfer-workbench' ? 'flex-1 overflow-hidden' : 'overflow-hidden'}>
<CardContent className="p-0">
<div className="relative overflow-hidden rounded-2xl bg-[radial-gradient(circle_at_top_left,rgba(51,110,255,0.22),transparent_45%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.8))] p-6">
<div className="relative h-full overflow-hidden rounded-2xl bg-[radial-gradient(circle_at_top_left,rgba(51,110,255,0.22),transparent_45%),linear-gradient(135deg,rgba(15,23,42,0.94),rgba(15,23,42,0.8))] p-6">
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-cyan-400/10 blur-2xl" />
<div className="relative z-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-3">
@@ -326,9 +337,45 @@ export default function Overview() {
</div>
</CardContent>
</Card>
{desktopSections.main.includes('apk-download') ? (
<Card className={`${desktopStretchSection === 'apk-download' ? 'flex-1' : ''} overflow-hidden border-[#336EFF]/20 bg-[radial-gradient(circle_at_top_right,rgba(51,110,255,0.18),transparent_40%),linear-gradient(180deg,rgba(10,14,28,0.96),rgba(15,23,42,0.92))]`}>
<CardContent className="h-full p-0">
<div className="relative flex h-full flex-col overflow-hidden rounded-2xl p-6">
<div className="absolute -left-12 bottom-0 h-28 w-28 rounded-full bg-[#336EFF]/10 blur-3xl" />
<div className="relative z-10 flex h-full flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-[#336EFF]/20 bg-[#336EFF]/10 px-3 py-1 text-xs font-medium text-[#b9ccff]">
<Smartphone className="h-3.5 w-3.5" />
Android
</div>
<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
</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>
</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
</a>
</div>
</div>
</CardContent>
</Card>
) : null}
</div>
<div className="space-y-6">
<div className="flex flex-col gap-6">
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
@@ -353,7 +400,7 @@ export default function Overview() {
<p className="text-3xl font-bold text-white tracking-tight">
{usedGb.toFixed(2)} <span className="text-sm text-slate-400 font-normal">GB</span>
</p>
<p className="text-xs text-slate-500 uppercase tracking-wider">使 / 50 GB</p>
<p className="text-xs text-slate-500 uppercase tracking-wider">{getOverviewStorageQuotaLabel(storageQuotaBytes)}</p>
</div>
<span className="text-xl font-mono text-[#336EFF] font-medium">{storagePercent.toFixed(1)}%</span>
</div>

View File

@@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Clock3, Folder, RefreshCw, RotateCcw, Trash2 } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { apiRequest } from '@/src/lib/api';
import type { PageResponse, RecycleBinItem } from '@/src/lib/types';
import { formatRecycleBinExpiresLabel, RECYCLE_BIN_RETENTION_DAYS } from './recycle-bin-state';
function formatFileSize(size: number) {
if (size <= 0) {
return '—';
}
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
}
export default function RecycleBin() {
const [items, setItems] = useState<RecycleBinItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [restoringId, setRestoringId] = useState<number | null>(null);
const loadRecycleBin = async () => {
setLoading(true);
setError('');
try {
const response = await apiRequest<PageResponse<RecycleBinItem>>('/files/recycle-bin?page=0&size=100');
setItems(response.items);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '回收站加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecycleBin();
}, []);
const handleRestore = async (itemId: number) => {
setRestoringId(itemId);
setError('');
try {
await apiRequest(`/files/recycle-bin/${itemId}/restore`, {
method: 'POST',
});
setItems((previous) => previous.filter((item) => item.id !== itemId));
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '恢复失败');
} finally {
setRestoringId(null);
}
};
return (
<div className="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
<Card className="overflow-hidden">
<CardHeader className="flex flex-col gap-4 border-b border-white/10 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-slate-300">
<Trash2 className="h-3.5 w-3.5" />
{RECYCLE_BIN_RETENTION_DAYS}
</div>
<CardTitle className="text-2xl text-white"></CardTitle>
<p className="text-sm text-slate-400">
{RECYCLE_BIN_RETENTION_DAYS}
</p>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="border-white/10 bg-white/5 text-slate-200 hover:bg-white/10"
onClick={() => void loadRecycleBin()}
disabled={loading}
>
<RefreshCw className="mr-2 h-4 w-4" />
</Button>
<Link
to="/files"
className="inline-flex h-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition-colors hover:bg-white/10"
>
</Link>
</div>
</CardHeader>
<CardContent className="p-6">
{error ? (
<div className="mb-4 rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-200">
{error}
</div>
) : null}
{loading ? (
<div className="flex min-h-64 items-center justify-center text-sm text-slate-400">
...
</div>
) : items.length === 0 ? (
<div className="flex min-h-64 flex-col items-center justify-center gap-4 rounded-3xl border border-dashed border-white/10 bg-black/10 text-center">
<div className="rounded-3xl border border-white/10 bg-white/5 p-4">
<Trash2 className="h-8 w-8 text-slate-400" />
</div>
<div className="space-y-1">
<p className="text-lg font-medium text-white"></p>
<p className="text-sm text-slate-400"> 10 </p>
</div>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-4 rounded-3xl border border-white/10 bg-black/10 p-5 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0 space-y-3">
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-3 text-slate-200">
<Folder className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="truncate text-base font-semibold text-white">{item.filename}</p>
<p className="truncate text-sm text-slate-400">{item.path}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>{item.directory ? '文件夹' : formatFileSize(item.size)}</span>
<span> {formatDateTime(item.deletedAt)}</span>
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-amber-200">
<Clock3 className="h-3.5 w-3.5" />
{formatRecycleBinExpiresLabel(item.expiresAt)}
</span>
</div>
</div>
<Button
className="min-w-28 self-start bg-[#336EFF] text-white hover:bg-[#2958cc] lg:self-center"
onClick={() => void handleRestore(item.id)}
disabled={restoringId === item.id}
>
<RotateCcw className="mr-2 h-4 w-4" />
{restoringId === item.id ? '恢复中' : '恢复'}
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,7 +1,16 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getOverviewLoadErrorMessage } from './overview-state';
import {
APK_DOWNLOAD_PATH,
APK_DOWNLOAD_PUBLIC_URL,
getDesktopOverviewSectionColumns,
getDesktopOverviewStretchSection,
getMobileOverviewApkEntryMode,
getOverviewLoadErrorMessage,
getOverviewStorageQuotaLabel,
shouldShowOverviewApkDownload,
} from './overview-state';
test('post-login failures are presented as overview initialization issues', () => {
assert.equal(
@@ -16,3 +25,42 @@ 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 hides the apk download entry inside the native app shell', () => {
assert.equal(shouldShowOverviewApkDownload(new URL('https://yoyuzh.xyz')), true);
assert.equal(shouldShowOverviewApkDownload(new URL('https://localhost')), false);
});
test('mobile overview switches from download mode to update mode inside the native shell', () => {
assert.equal(getMobileOverviewApkEntryMode(new URL('https://yoyuzh.xyz')), 'download');
assert.equal(getMobileOverviewApkEntryMode(new URL('https://localhost')), 'update');
});
test('desktop overview places the apk card in the main column to avoid empty left-side space', () => {
assert.deepEqual(getDesktopOverviewSectionColumns(true), {
main: ['recent-files', 'transfer-workbench', 'apk-download'],
sidebar: ['quick-actions', 'storage', 'account'],
});
});
test('desktop overview omits the apk card entirely when the download entry is hidden', () => {
assert.deepEqual(getDesktopOverviewSectionColumns(false), {
main: ['recent-files', 'transfer-workbench'],
sidebar: ['quick-actions', 'storage', 'account'],
});
});
test('desktop overview stretches the last visible main card to keep column bottoms aligned', () => {
assert.equal(getDesktopOverviewStretchSection(true), 'apk-download');
assert.equal(getDesktopOverviewStretchSection(false), 'transfer-workbench');
});
test('overview storage quota label uses the real quota instead of a fixed 50 GB copy', () => {
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
});

View File

@@ -1,3 +1,36 @@
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';
function formatOverviewStorageSize(size: number) {
if (size <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
export function getDesktopOverviewSectionColumns(showApkDownload: boolean) {
return {
main: showApkDownload
? ['recent-files', 'transfer-workbench', 'apk-download']
: ['recent-files', 'transfer-workbench'],
sidebar: ['quick-actions', 'storage', 'account'],
};
}
export function getDesktopOverviewStretchSection(showApkDownload: boolean) {
return showApkDownload ? 'apk-download' : 'transfer-workbench';
}
export function getOverviewStorageQuotaLabel(storageQuotaBytes: number) {
return `已使用 / 共 ${formatOverviewStorageSize(storageQuotaBytes)}`;
}
export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) {
if (isPostLoginFailure) {
return '登录已成功,但总览数据加载失败,请稍后重试。';
@@ -5,3 +38,23 @@ export function getOverviewLoadErrorMessage(isPostLoginFailure: boolean) {
return '总览数据加载失败,请稍后重试。';
}
function resolveOverviewLocation() {
if (typeof globalThis.location !== 'undefined') {
return globalThis.location;
}
if (typeof window !== 'undefined') {
return window.location;
}
return null;
}
export function shouldShowOverviewApkDownload(location: Location | URL | null = resolveOverviewLocation()) {
return !isNativeAppShellLocation(location);
}
export function getMobileOverviewApkEntryMode(location: Location | URL | null = resolveOverviewLocation()) {
return isNativeAppShellLocation(location) ? 'update' : 'download';
}

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
RECYCLE_BIN_ROUTE,
RECYCLE_BIN_RETENTION_DAYS,
formatRecycleBinExpiresLabel,
getFilesSidebarFooterEntries,
} from './recycle-bin-state';
test('files sidebar keeps the recycle bin entry at the bottom footer area', () => {
const footerEntries = getFilesSidebarFooterEntries();
assert.equal(footerEntries.at(-1)?.path, RECYCLE_BIN_ROUTE);
assert.equal(footerEntries.at(-1)?.label, '回收站');
});
test('recycle bin retention stays fixed at ten days', () => {
assert.equal(RECYCLE_BIN_RETENTION_DAYS, 10);
});
test('recycle bin expiry labels show the remaining days before purge', () => {
assert.equal(
formatRecycleBinExpiresLabel('2026-04-13T10:00:00', new Date('2026-04-03T10:00:00')),
'10 天后清理'
);
assert.equal(
formatRecycleBinExpiresLabel('2026-04-04T09:00:00', new Date('2026-04-03T10:00:00')),
'1 天后清理'
);
});

View File

@@ -0,0 +1,27 @@
export const RECYCLE_BIN_ROUTE = '/recycle-bin';
export const RECYCLE_BIN_RETENTION_DAYS = 10;
export interface FilesSidebarFooterEntry {
label: string;
path: string;
}
export function getFilesSidebarFooterEntries(): FilesSidebarFooterEntry[] {
return [
{
label: '回收站',
path: RECYCLE_BIN_ROUTE,
},
];
}
export function formatRecycleBinExpiresLabel(expiresAt: string, now = new Date()) {
const expiresAtDate = new Date(expiresAt);
const diffMs = expiresAtDate.getTime() - now.getTime();
if (Number.isNaN(expiresAtDate.getTime()) || diffMs <= 0) {
return '今天清理';
}
const remainingDays = Math.max(1, Math.ceil(diffMs / (24 * 60 * 60 * 1000)));
return `${remainingDays} 天后清理`;
}