Enable dual-device login and mobile APK update checks
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
11
front/src/components/ui/card.test.tsx
Normal file
11
front/src/components/ui/card.test.tsx
Normal 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\)\]/);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
165
front/src/pages/RecycleBin.tsx
Normal file
165
front/src/pages/RecycleBin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
31
front/src/pages/recycle-bin-state.test.ts
Normal file
31
front/src/pages/recycle-bin-state.test.ts
Normal 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 天后清理'
|
||||
);
|
||||
});
|
||||
27
front/src/pages/recycle-bin-state.ts
Normal file
27
front/src/pages/recycle-bin-state.ts
Normal 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} 天后清理`;
|
||||
}
|
||||
Reference in New Issue
Block a user