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

@@ -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} 天后清理`;
}