Enable dual-device login and mobile APK update checks
This commit is contained in:
@@ -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