Refactor backend and frontend modules for architecture alignment

This commit is contained in:
yoyuzh
2026-04-12 00:32:21 +08:00
parent f59515f5dd
commit 30a9bbc1e7
253 changed files with 25462 additions and 4786 deletions

9
front_zip/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
front_zip/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

20
front_zip/README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/52ed7feb-11e7-46f2-aac1-69c955c09846
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

16
front_zip/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<title>Stitch Portal</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
front_zip/metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "",
"description": "",
"requestFramePermissions": []
}

4383
front_zip/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
front_zip/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.14.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

58
front_zip/src/App.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'motion/react';
import AdminDashboard from './admin/dashboard';
import AdminFilesList from './admin/files-list';
import AdminStoragePoliciesList from './admin/storage-policies-list';
import AdminUsersList from './admin/users-list';
import Layout from './components/layout/Layout';
import MobileLayout from './mobile-components/MobileLayout';
import { useIsMobile } from './hooks/useIsMobile';
import Login from './pages/Login';
import Overview from './pages/Overview';
import RecycleBin from './pages/RecycleBin';
import Shares from './pages/Shares';
import Tasks from './pages/Tasks';
import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import FilesPage from './pages/files/FilesPage';
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
const location = useLocation();
const AppLayout = isMobile ? MobileLayout : Layout;
return (
<AnimatePresence mode="wait">
<Routes location={location}>
<Route path="/login" element={<Login />} />
<Route path="/share/:token" element={<FileShare />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<Overview />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/shares" element={<Shares />} />
<Route path="/recycle-bin" element={<RecycleBin />} />
<Route path="/transfer" element={<Transfer />} />
<Route path="/admin">
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={isMobile ? <Navigate to="/overview" replace /> : <AdminDashboard />} />
<Route path="users" element={isMobile ? <Navigate to="/overview" replace /> : <AdminUsersList />} />
<Route path="files" element={isMobile ? <Navigate to="/overview" replace /> : <AdminFilesList />} />
<Route path="storage-policies" element={isMobile ? <Navigate to="/overview" replace /> : <AdminStoragePoliciesList />} />
</Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Route>
</Routes>
</AnimatePresence>
);
}
export default function App() {
const isMobile = useIsMobile();
return (
<BrowserRouter>
<AnimatedRoutes isMobile={isMobile} />
</BrowserRouter>
);
}

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import { Copy, Database, HardDrive, RefreshCw, Send, Users, ChevronRight, Activity } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { Link } from 'react-router-dom';
import { motion } from 'motion/react';
import { getAdminSummary, type AdminSummary } from '@/src/lib/admin';
import { formatBytes } from '@/src/lib/format';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function AdminDashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [summary, setSummary] = useState<AdminSummary | null>(null);
async function loadSummary() {
setError('');
try {
setSummary(await getAdminSummary());
} catch (err) {
setError(err instanceof Error ? err.message : '加载后台总览失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadSummary();
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadSummary();
}}
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
{loading && !summary ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : summary ? (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="space-y-10"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-blue-500/30 transition-all">
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-blue-500/10 border border-blue-500/20 shadow-[0_0_15px_rgba(59,130,246,0.1)]">
<Users className="h-7 w-7 text-blue-500" />
</div>
<h3 className="text-4xl font-black tracking-tight group-hover:text-blue-500 transition-colors">{summary.totalUsers}</h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</motion.div>
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-green-500/30 transition-all">
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-green-500/10 border border-green-500/20 shadow-[0_0_15px_rgba(34,197,94,0.1)]">
<HardDrive className="h-7 w-7 text-green-500" />
</div>
<h3 className="text-4xl font-black tracking-tight group-hover:text-green-500 transition-colors">{summary.totalFiles}</h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</motion.div>
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-purple-500/30 transition-all">
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-purple-500/10 border border-purple-500/20 shadow-[0_0_15px_rgba(168,85,247,0.1)]">
<Database className="h-7 w-7 text-purple-500" />
</div>
<h3 className="text-4xl font-black tracking-tight group-hover:text-purple-500 transition-colors">{formatBytes(summary.totalStorageBytes).split(' ')[0]}<span className="text-xl ml-1 opacity-40">{formatBytes(summary.totalStorageBytes).split(' ')[1]}</span></h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</motion.div>
<motion.div variants={itemVariants} className="glass-panel-no-hover rounded-lg p-8 shadow-2xl border border-white/10 group hover:border-amber-500/30 transition-all">
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-lg bg-amber-500/10 border border-amber-500/20 shadow-[0_0_15px_rgba(245,158,11,0.1)]">
<Send className="h-7 w-7 text-amber-500" />
</div>
<h3 className="text-4xl font-black tracking-tight group-hover:text-amber-500 transition-colors">{formatBytes(summary.offlineTransferStorageBytes).split(' ')[0]}<span className="text-xl ml-1 opacity-40">{formatBytes(summary.offlineTransferStorageBytes).split(' ')[1]}</span></h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</motion.div>
</div>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2">
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
<div className="mb-8">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
</div>
<div className="grid grid-cols-1 gap-4">
<Link to="/admin/users" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-blue-500/30 transition-all group">
<div className="flex items-center gap-5">
<div className="p-3 rounded-lg bg-blue-500/10 group-hover:bg-blue-600 text-blue-500 group-hover:text-white transition-all shadow-inner">
<Users className="h-6 w-6" />
</div>
<div>
<span className="text-[11px] font-black uppercase tracking-widest block"></span>
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity"></span>
</div>
</div>
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
</Link>
<Link to="/admin/files" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-green-500/30 transition-all group">
<div className="flex items-center gap-5">
<div className="p-3 rounded-lg bg-green-500/10 group-hover:bg-green-600 text-green-500 group-hover:text-white transition-all shadow-inner">
<HardDrive className="h-6 w-6" />
</div>
<div>
<span className="text-[11px] font-black uppercase tracking-widest block"></span>
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity"></span>
</div>
</div>
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
</Link>
<Link to="/admin/storage-policies" className="flex items-center justify-between p-6 rounded-lg bg-white/5 border border-white/5 hover:bg-white/10 hover:border-purple-500/30 transition-all group">
<div className="flex items-center gap-5">
<div className="p-3 rounded-lg bg-purple-500/10 group-hover:bg-purple-600 text-purple-500 group-hover:text-white transition-all shadow-inner">
<Database className="h-6 w-6" />
</div>
<div>
<span className="text-[11px] font-black uppercase tracking-widest block"></span>
<span className="text-[9px] font-bold opacity-30 uppercase tracking-widest mt-1 block group-hover:opacity-60 transition-opacity"></span>
</div>
</div>
<ChevronRight className="h-5 w-5 opacity-20 group-hover:opacity-100 group-hover:translate-x-1 transition-all" />
</Link>
</div>
</motion.section>
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
<div className="mb-8 flex items-center justify-between">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<div className="flex items-center gap-3 text-[9px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-green-500/10 text-green-500 border border-green-500/20 shadow-inner">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]"></span>
</div>
</div>
<div className="space-y-8">
<div className="p-8 rounded-lg bg-black/40 border border-white/5">
<div className="flex items-center justify-between mb-6">
<span className="text-[9px] font-black uppercase tracking-[0.3em] opacity-30"></span>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(summary.inviteCode); window.alert('邀请码已复制'); }}
className="p-2 rounded-lg hover:bg-white/10 transition-all opacity-40 hover:opacity-100"
>
<Copy className="h-4 w-4" />
</button>
</div>
<div className="text-4xl font-black tracking-[0.4em] text-center p-8 bg-blue-500/5 rounded-lg border border-white/5 text-blue-500/80 drop-shadow-2xl">
{summary.inviteCode}
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="p-6 rounded-lg bg-white/5 border border-white/5 group hover:border-white/20 transition-all">
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 flex items-center gap-2">
<Activity className="h-3 w-3" />
</div>
<div className="text-2xl font-black tracking-tight">{formatBytes(summary.downloadTrafficBytes)}</div>
</div>
<div className="p-6 rounded-lg bg-white/5 border border-white/5 group hover:border-white/20 transition-all">
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30 mb-3 flex items-center gap-2">
<Activity className="h-3 w-3" />
</div>
<div className="text-2xl font-black tracking-tight group-hover:text-blue-500 transition-colors font-black">{summary.requestCount} <span className="text-xs opacity-40 ml-1"></span></div>
</div>
</div>
</div>
</motion.section>
</div>
</motion.div>
) : null}
</motion.div>
);
}

View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react';
import { RefreshCw, Search, Trash2, Folder, FileText, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { deleteAdminFile, listAdminFiles, type AdminFile } from '@/src/lib/admin';
import { formatBytes, formatDateTime } from '@/src/lib/format';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function AdminFilesList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [ownerQuery, setOwnerQuery] = useState('');
const [files, setFiles] = useState<AdminFile[]>([]);
async function loadFiles(nextQuery = query, nextOwnerQuery = ownerQuery) {
setError('');
try {
const result = await listAdminFiles(0, 100, nextQuery, nextOwnerQuery);
setFiles(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载文件失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFiles();
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadFiles();
}}
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
<div className="mb-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="relative group">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadFiles(event.currentTarget.value, ownerQuery);
}
}}
placeholder="搜索文件名或路径...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
</div>
<div className="relative group">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={ownerQuery}
onChange={(event) => setOwnerQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadFiles(query, event.currentTarget.value);
}
}}
placeholder="搜索所属用户...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading && files.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{files.map((file) => (
<motion.tr key={file.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="text-[12px] font-black tracking-tight uppercase group-hover:text-blue-500 transition-colors uppercase">{file.filename}</div>
<div className="mt-1 text-[9px] opacity-30 font-black uppercase tracking-widest truncate max-w-xs">{file.path}</div>
</td>
<td className="px-8 py-5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-blue-500 uppercase tracking-widest">{file.ownerUsername || file.ownerEmail}</span>
</div>
</td>
<td className="px-8 py-5">
<div className="text-[11px] font-black uppercase tracking-widest">{file.directory ? '-' : formatBytes(file.size)}</div>
<div className="text-[9px] opacity-20 font-black tracking-[0.2em] uppercase mt-1">
{file.directory ? '目录' : '文件'}
</div>
</td>
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
{formatDateTime(file.createdAt)}
</td>
<td className="px-8 py-5 text-right">
<button
type="button"
onClick={async () => {
if (!window.confirm(`确认物理擦除 ${file.filename} 吗?此操作将触发硬件级销毁。`)) {
return;
}
await deleteAdminFile(file.id);
await loadFiles();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-red-600 hover:text-white text-red-500 border border-white/10 transition-all opacity-0 group-hover:opacity-100 shadow-sm"
title="彻底删除"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</motion.tr>
))}
{files.length === 0 && (
<tr>
<td colSpan={5} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
</td>
</tr>
)}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,383 @@
import { useEffect, useState } from 'react';
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import {
createStorageMigration,
createStoragePolicy,
getStoragePolicies,
updateStoragePolicy,
updateStoragePolicyStatus,
type AdminStoragePolicy,
type StoragePolicyCapabilities,
type StoragePolicyUpsertPayload,
} from '@/src/lib/admin-storage-policies';
import { formatBytes } from '@/src/lib/format';
import { cn } from '@/src/lib/utils';
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
return {
directUpload: false,
multipartUpload: false,
signedDownloadUrl: false,
serverProxyDownload: true,
thumbnailNative: false,
friendlyDownloadName: false,
requiresCors: false,
supportsInternalEndpoint: false,
maxObjectSize,
};
}
function buildInitialForm(policy?: AdminStoragePolicy): StoragePolicyUpsertPayload {
if (policy) {
return {
name: policy.name,
type: policy.type,
bucketName: policy.bucketName ?? '',
endpoint: policy.endpoint ?? '',
region: policy.region ?? '',
privateBucket: policy.privateBucket,
prefix: policy.prefix ?? '',
credentialMode: policy.credentialMode,
maxSizeBytes: policy.maxSizeBytes,
capabilities: policy.capabilities,
enabled: policy.enabled,
};
}
return {
name: '',
type: 'LOCAL',
bucketName: '',
endpoint: '',
region: '',
privateBucket: false,
prefix: '',
credentialMode: 'NONE',
maxSizeBytes: 1024 * 1024 * 1024,
capabilities: createDefaultCapabilities(),
enabled: true,
};
}
export default function AdminStoragePoliciesList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [policies, setPolicies] = useState<AdminStoragePolicy[]>([]);
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
async function loadPolicies() {
setError('');
try {
setPolicies(await getStoragePolicies());
} catch (err) {
setError(err instanceof Error ? err.message : '加载存储策略失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadPolicies();
}, []);
async function savePolicy() {
try {
if (editingPolicy) {
await updateStoragePolicy(editingPolicy.id, form);
} else {
await createStoragePolicy(form);
}
setShowForm(false);
setEditingPolicy(null);
setForm(buildInitialForm());
await loadPolicies();
} catch (err) {
setError(err instanceof Error ? err.message : '保存策略失败');
}
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setLoading(true);
void loadPolicies();
}}
className="flex items-center gap-2 px-5 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
<button
type="button"
onClick={() => {
setEditingPolicy(null);
setForm(buildInitialForm());
setShowForm(true);
}}
className="flex items-center gap-2 px-6 py-3 rounded-lg bg-blue-600 text-white font-black text-[11px] uppercase tracking-[0.15em] shadow-lg hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading ? (
<div className="glass-panel rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel rounded-lg overflow-hidden shadow-xl border-white/20">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10 text-sm">
<thead className="bg-white/10 dark:bg-black/40 font-black uppercase tracking-[0.15em] text-[9px] opacity-40">
<tr>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left">访</th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-left"></th>
<th className="px-8 py-5 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10 dark:divide-white/5">
{policies.map((policy) => (
<tr key={policy.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="flex items-center gap-2 font-black text-[13px] tracking-tight">
{policy.name}
{policy.defaultPolicy ? (
<span className="rounded-sm bg-blue-500/20 text-blue-600 dark:text-blue-400 px-1.5 py-0.5 text-[8px] border border-blue-500/20 uppercase tracking-widest font-black"></span>
) : null}
</div>
<div className="text-[10px] font-bold opacity-30 mt-1 tracking-tighter">PID::{policy.id}</div>
</td>
<td className="px-8 py-5">
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">{policy.type}</span>
</td>
<td className="px-8 py-5">
<div className="truncate max-w-[180px] font-bold opacity-60 text-[11px] tracking-tight">{policy.endpoint || '-'}</div>
<div className="text-[9px] font-black text-blue-500 uppercase tracking-tighter mt-0.5">{policy.bucketName || '私有根路径'}</div>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-1.5 rounded-sm px-2 py-1 text-[9px] font-black uppercase tracking-widest border",
policy.enabled
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20"
: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20"
)}>
<span className={cn("w-1.5 h-1.5 rounded-full", policy.enabled ? "bg-green-500 animate-pulse" : "bg-red-500")}></span>
{policy.enabled ? '启用' : '停用'}
</span>
</td>
<td className="px-8 py-5 font-black opacity-60 text-xs tracking-tighter">
{formatBytes(policy.maxSizeBytes)}
</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2.5 opacity-40 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
setEditingPolicy(policy);
setForm(buildInitialForm(policy));
setShowForm(true);
}}
className="p-2 rounded-lg glass-panel hover:bg-white/40 text-gray-500 border-white/20 transition-all"
title="编辑策略"
>
<Edit2 className="h-4 w-4" />
</button>
{!policy.defaultPolicy ? (
<button
type="button"
onClick={async () => {
await updateStoragePolicyStatus(policy.id, !policy.enabled);
await loadPolicies();
}}
className={cn(
"p-2 rounded-lg glass-panel border-white/20 transition-all",
policy.enabled ? "text-amber-500 hover:bg-amber-500/10" : "text-green-500 hover:bg-green-500/10"
)}
title={policy.enabled ? '停用' : '启用'}
>
{policy.enabled ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
) : null}
<button
type="button"
onClick={async () => {
const targetId = window.prompt('请输入迁移目标策略 ID');
if (!targetId) return;
await createStorageMigration(policy.id, Number(targetId));
window.alert('已创建迁移任务');
}}
className="p-2 rounded-lg glass-panel hover:bg-blue-500/10 text-blue-500 border-white/20 transition-all"
title="发起迁移"
>
<ArrowRightLeft className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{showForm ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md px-4 py-8 overflow-y-auto mt-0">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-12 shadow-2xl border-white/20"
>
<h2 className="mb-10 text-3xl font-black tracking-tighter uppercase">{editingPolicy ? '编辑策略' : '新建策略'}</h2>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<select
value={form.type}
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value as StoragePolicyUpsertPayload['type'] }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
>
<option value="LOCAL"></option>
<option value="S3_COMPATIBLE">S3 </option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.endpoint || ''}
onChange={(event) => setForm((current) => ({ ...current, endpoint: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
value={form.bucketName || ''}
onChange={(event) => setForm((current) => ({ ...current, bucketName: event.target.value }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<input
type="number"
value={form.maxSizeBytes}
onChange={(event) => setForm((current) => ({ ...current, maxSizeBytes: Number(event.target.value), capabilities: { ...current.capabilities, maxObjectSize: Number(event.target.value) } }))}
className="w-full rounded-lg glass-panel bg-white/10 p-4 outline-none border-white/10 focus:border-blue-500/50 transition-all font-bold text-sm"
/>
</div>
</div>
<div className="mt-10 grid grid-cols-2 gap-4 text-[9px] font-black uppercase tracking-widest md:grid-cols-4">
{(
[
['privateBucket', '私有桶'],
['enabled', '启用'],
['capabilities.directUpload', '直传'],
['capabilities.multipartUpload', '分片上传'],
['capabilities.signedDownloadUrl', '签名下载'],
['capabilities.serverProxyDownload', '代理下载'],
['capabilities.requiresCors', '需要 CORS'],
['capabilities.supportsInternalEndpoint', '内网端点'],
] as const
).map(([key, label]) => {
const checked =
key === 'privateBucket'
? form.privateBucket
: key === 'enabled'
? form.enabled
: form.capabilities[key.replace('capabilities.', '') as keyof StoragePolicyCapabilities];
return (
<label key={key} className={cn(
"flex items-center gap-3 p-3 rounded-lg hover:bg-white/10 transition-all cursor-pointer border border-transparent group",
checked ? "bg-white/5 border-white/10" : "opacity-30"
)}>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const nextValue = event.target.checked;
if (key === 'privateBucket') {
setForm((current) => ({ ...current, privateBucket: nextValue }));
return;
}
if (key === 'enabled') {
setForm((current) => ({ ...current, enabled: nextValue }));
return;
}
const capabilityKey = key.replace('capabilities.', '') as keyof StoragePolicyCapabilities;
setForm((current) => ({
...current,
capabilities: {
...current.capabilities,
[capabilityKey]: nextValue,
},
}));
}}
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 text-blue-600 focus:ring-0"
/>
<span className={cn("transition-colors", checked ? "text-blue-500" : "")}>
{label}
</span>
</label>
);
})}
</div>
<div className="mt-12 flex justify-end gap-3">
<button
type="button"
onClick={() => {
setShowForm(false);
setEditingPolicy(null);
}}
className="px-8 py-4 rounded-lg glass-panel hover:bg-white/40 text-[11px] font-black uppercase tracking-widest transition-all"
>
</button>
<button
type="button"
onClick={() => void savePolicy()}
className="px-10 py-4 rounded-lg bg-blue-600 text-white text-[11px] font-black uppercase tracking-widest shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
</button>
</div>
</motion.div>
</div>
) : null}
</motion.div>
);
}

View File

@@ -0,0 +1,260 @@
import { useEffect, useState } from 'react';
import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import {
getAdminUsers,
resetUserPassword,
updateUserMaxUploadSize,
updateUserPassword,
updateUserRole,
updateUserStatus,
updateUserStorageQuota,
type AdminUser,
} from '@/src/lib/admin-users';
import { formatBytes, formatDateTime } from '@/src/lib/format';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function AdminUsersList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<AdminUser[]>([]);
async function loadUsers(nextQuery = query) {
setError('');
try {
const result = await getAdminUsers(0, 100, nextQuery);
setUsers(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadUsers();
}, []);
async function mutate(action: () => Promise<unknown>) {
try {
await action();
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败');
}
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10 flex items-center justify-between">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadUsers();
}}
className="flex items-center gap-3 px-6 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-[11px] uppercase tracking-widest"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
<div className="mb-10 group">
<div className="relative">
<Search className="absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 group-focus-within:text-blue-500 transition-colors" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadUsers(event.currentTarget.value);
}
}}
placeholder="搜索用户名、邮箱或手机号...(回车)"
className="w-full rounded-lg glass-panel bg-white/10 py-5 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20"
/>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold backdrop-blur-md uppercase tracking-widest">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading && users.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-3xl border border-white/10">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-right text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{users.map((user) => (
<motion.tr key={user.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-blue-500/10 flex items-center justify-center font-black text-blue-500 border border-blue-500/20 shadow-inner">
{user.username.charAt(0).toUpperCase()}
</div>
<div>
<div className="text-[12px] font-black tracking-tight uppercase">{user.username}</div>
<div className="text-[10px] opacity-40 font-bold flex items-center gap-1.5 mt-0.5"><Mail className="h-3 w-3" /> {user.email}</div>
{user.phoneNumber ? <div className="mt-1 text-[9px] font-black opacity-20 tracking-widest flex items-center gap-1.5"><Phone className="h-3 w-3" />{user.phoneNumber}</div> : null}
</div>
</div>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border shadow-inner",
user.role === 'ADMIN'
? "bg-purple-500/10 text-purple-500 border-purple-500/20"
: "bg-blue-500/10 text-blue-500 border-blue-500/20"
)}>
<Shield className="h-3 w-3" />
{user.role}
</span>
</td>
<td className="px-8 py-5">
<span className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
user.banned
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
)}>
{user.banned ? '已禁用' : '正常'}
</span>
</td>
<td className="px-8 py-5">
<div className="text-[10px] font-black uppercase tracking-tight">
{formatBytes(user.usedStorageBytes)} / <span className="opacity-30">{formatBytes(user.storageQuotaBytes)}</span>
</div>
<div className="mt-2 h-1 w-full max-w-[120px] rounded-full bg-white/10 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${Math.min(100, (user.usedStorageBytes / user.storageQuotaBytes) * 100)}%` }}
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
></motion.div>
</div>
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
{formatBytes(user.maxUploadSizeBytes)}
</div>
</td>
<td className="px-8 py-5 text-[10px] font-bold opacity-30 tracking-tighter uppercase">
{formatDateTime(user.createdAt)}
</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() =>
void mutate(async () => {
const nextRole = window.prompt('设置角色USER 或 ADMIN', user.role);
if (!nextRole || (nextRole !== 'USER' && nextRole !== 'ADMIN')) {
return;
}
await updateUserRole(user.id, nextRole);
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="修改角色"
>
<Shield className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
void mutate(async () => {
const nextQuota = window.prompt('设置存储配额(字节)', String(user.storageQuotaBytes));
if (!nextQuota) return;
await updateUserStorageQuota(user.id, Number(nextQuota));
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="修改配额"
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
void mutate(async () => {
const newPassword = window.prompt('设置新密码');
if (!newPassword) return;
await updateUserPassword(user.id, newPassword);
})
}
className="p-2.5 rounded-lg glass-panel hover:bg-amber-500 hover:text-white text-amber-500 transition-all border-white/10"
title="重置密码"
>
<KeyRound className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void mutate(() => updateUserStatus(user.id, !user.banned))}
className={cn(
"p-2.5 rounded-lg glass-panel border border-white/10 transition-all",
user.banned ? "hover:bg-green-500 hover:text-white text-green-500" : "hover:bg-red-500 hover:text-white text-red-500"
)}
title={user.banned ? '恢复账号' : '禁用账号'}
>
<Ban className="h-4 w-4" />
</button>
</div>
</td>
</motion.tr>
))}
{users.length === 0 ? (
<tr>
<td colSpan={6} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
</td>
</tr>
) : null}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,73 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'vite-ui-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -0,0 +1,17 @@
import { Moon, Sun } from 'lucide-react';
import { useTheme } from './ThemeProvider';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="p-2 rounded-xl glass-panel hover:bg-white/40 dark:hover:bg-white/10 transition-all border border-white/20"
aria-label="Toggle theme"
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100 top-2 left-2" />
</button>
);
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import {
HardDrive,
LayoutDashboard,
ListTodo,
LogOut,
Send,
Settings,
Share2,
Trash2,
Sun,
Moon,
} from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { logout } from '@/src/lib/auth';
import { getSession, type PortalSession } from '@/src/lib/session';
import { useTheme } from '../ThemeProvider';
export default function Layout() {
const navigate = useNavigate();
const [session, setSession] = useState<PortalSession | null>(() => getSession());
const { theme, setTheme } = useTheme();
useEffect(() => {
const handleSessionChange = (event: Event) => {
const customEvent = event as CustomEvent<PortalSession | null>;
setSession(customEvent.detail ?? getSession());
};
window.addEventListener('portal-session-changed', handleSessionChange);
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
}, []);
useEffect(() => {
if (!session) {
navigate('/login', { replace: true });
}
}, [navigate, session]);
const navItems = [
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
{ to: '/files', icon: HardDrive, label: '网盘' },
{ to: '/tasks', icon: ListTodo, label: '任务' },
{ to: '/shares', icon: Share2, label: '分享' },
{ to: '/recycle-bin', icon: Trash2, label: '回收站' },
{ to: '/transfer', icon: Send, label: '快传' },
...(session?.user.role === 'ADMIN'
? [{ to: '/admin/dashboard', icon: Settings, label: '后台' }]
: []),
];
return (
<div className="flex h-screen w-full bg-aurora text-gray-900 dark:text-gray-100 overflow-hidden">
{/* Sidebar */}
<aside className="w-68 flex-shrink-0 border-r border-white/20 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl flex flex-col z-20 shadow-xl">
<div className="h-24 flex items-center justify-between px-8 border-b border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white font-black shadow-lg text-lg tracking-tighter">P</div>
<span className="text-2xl font-black tracking-tight uppercase"></span>
</div>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2.5 rounded-lg glass-panel hover:bg-white/50 transition-all font-bold"
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-300" /> : <Moon className="w-5 h-5 text-gray-700" />}
</button>
</div>
<div className="border-b border-white/10 px-8 py-6">
<div className="text-sm font-black uppercase tracking-[0.2em] opacity-70 mb-1"></div>
<div className="text-sm font-black truncate">
{session?.user.displayName || session?.user.username || '游客用户'}
</div>
<div className="truncate text-sm font-bold opacity-80 dark:opacity-90 flex items-center gap-1.5 mt-2 uppercase tracking-tight">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_10px_rgba(34,197,94,0.6)] animate-pulse"></span>
{session?.user.email || '未登录'}
</div>
</div>
<nav className="flex-1 overflow-y-auto py-8 px-5 space-y-1.5">
<div className="px-3 mb-2 text-xs font-black uppercase tracking-[0.3em] opacity-70"></div>
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex items-center gap-3 px-4 py-3.5 rounded-lg text-sm font-black uppercase tracking-widest transition-all duration-300 group",
isActive
? "glass-panel-no-hover bg-white/60 dark:bg-white/10 shadow-lg text-blue-600 dark:text-blue-400 border-white/40"
: "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5 hover:translate-x-1"
)
}
>
<item.icon className={cn("h-4 w-4 transition-colors group-hover:text-blue-500")} />
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-white/10 p-6">
<button
type="button"
onClick={() => {
logout();
navigate('/login');
}}
className="flex w-full items-center gap-3 rounded-lg px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:text-red-500 transition-all hover:bg-white/20 dark:hover:bg-white/5"
>
<LogOut className="h-4 w-4 opacity-60" />
退
</button>
</div>
</aside>
<main className="relative flex min-w-0 flex-1 flex-col overflow-hidden z-10">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIsMobile = () => {
// Determine mobile based on aspect ratio (width / height < 1) or width < 768
const isPortrait = window.innerWidth / window.innerHeight < 1;
const isSmallScreen = window.innerWidth < 768;
setIsMobile(isPortrait || isSmallScreen);
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
}, []);
return isMobile;
}

146
front_zip/src/index.css Normal file
View File

@@ -0,0 +1,146 @@
@import "tailwindcss";
@theme {
--color-aurora-1: #c4d9ff;
--color-aurora-2: #ffd1eb;
--color-aurora-3: #fff1c4;
--color-aurora-4: #c4fff2;
--color-aurora-5: #e0c4ff;
}
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222.2 84% 4.9%;
--glass-bg: rgba(255, 255, 255, 0.4);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-hover: rgba(255, 255, 255, 0.5);
--glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1), 0 8px 32px 0 rgba(31, 38, 135, 0.07);
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--glass-bg: rgba(15, 23, 42, 0.4);
--glass-border: rgba(255, 255, 255, 0.05);
--glass-hover: rgba(15, 23, 42, 0.6);
--glass-inner-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}
body {
font-family: 'Inter', sans-serif;
color: hsl(var(--foreground));
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Outfit', sans-serif;
}
}
@layer utilities {
.bg-aurora {
background-color: #c4d9ff;
background-image:
radial-gradient(at 0% 0%, #ffd1eb 0px, transparent 50%),
radial-gradient(at 100% 0%, #fff1c4 0px, transparent 50%),
radial-gradient(at 100% 100%, #c4fff2 0px, transparent 50%),
radial-gradient(at 0% 100%, #e0c4ff 0px, transparent 50%),
radial-gradient(at 50% 50%, #f1f5f9 0px, transparent 50%);
background-size: 200% 200%;
animation: aurora 45s ease infinite alternate;
}
.dark .bg-aurora {
background-color: #020617;
background-image:
radial-gradient(at 0% 0%, #171717 0px, transparent 50%),
radial-gradient(at 100% 0%, #0c0a09 0px, transparent 50%),
radial-gradient(at 100% 100%, #0f172a 0px, transparent 50%),
radial-gradient(at 0% 100%, #1e1b4b 0px, transparent 50%),
radial-gradient(at 50% 50%, #020617 0px, transparent 50%);
}
.glass-panel {
background-color: var(--glass-bg);
backdrop-filter: blur(24px) saturate(180%);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-inner-shadow);
border-radius: 8px; /* 8px Professional Radius */
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-panel:hover {
background-color: var(--glass-hover);
transform: translateY(-2px);
box-shadow: var(--glass-inner-shadow), 0 12px 40px -10px rgba(0, 0, 0, 0.1);
}
.glass-panel-no-hover {
background-color: var(--glass-bg);
backdrop-filter: blur(24px) saturate(180%);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-inner-shadow);
border-radius: 8px;
}
/* Animations */
.animate-page-entry {
animation: page-entry 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-text-reveal {
animation: text-reveal 1.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
background-clip: text;
-webkit-background-clip: text;
}
.animate-scan {
animation: scan 4s linear infinite;
}
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
}
@keyframes aurora {
0% { background-position: 0% 0%; }
50% { background-position: 100% 100%; }
100% { background-position: 0% 100%; }
}
@keyframes page-entry {
from {
opacity: 0;
transform: translateY(20px) scale(1.02);
filter: blur(10px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes text-reveal {
from {
opacity: 0;
letter-spacing: 0.4em;
filter: blur(12px);
}
to {
opacity: 1;
letter-spacing: normal;
filter: blur(0);
}
}
@keyframes scan {
0% { transform: translateY(-100%); opacity: 0; }
10% { opacity: 0.5; }
90% { opacity: 0.5; }
100% { transform: translateY(120vh); opacity: 0; }
}

View File

@@ -0,0 +1,77 @@
import { fetchApi } from './api';
export type StoragePolicyCapabilities = {
directUpload: boolean;
multipartUpload: boolean;
signedDownloadUrl: boolean;
serverProxyDownload: boolean;
thumbnailNative: boolean;
friendlyDownloadName: boolean;
requiresCors: boolean;
supportsInternalEndpoint: boolean;
maxObjectSize: number;
};
export type AdminStoragePolicy = {
id: number;
name: string;
type: 'LOCAL' | 'S3_COMPATIBLE';
bucketName: string | null;
endpoint: string | null;
region: string | null;
privateBucket: boolean;
prefix: string | null;
credentialMode: 'NONE' | 'STATIC' | 'DOGECLOUD_TEMP';
maxSizeBytes: number;
capabilities: StoragePolicyCapabilities;
enabled: boolean;
defaultPolicy: boolean;
createdAt: string;
updatedAt: string;
};
export type StoragePolicyUpsertPayload = {
name: string;
type: AdminStoragePolicy['type'];
bucketName?: string;
endpoint?: string;
region?: string;
privateBucket: boolean;
prefix?: string;
credentialMode: AdminStoragePolicy['credentialMode'];
maxSizeBytes: number;
capabilities: StoragePolicyCapabilities;
enabled: boolean;
};
export async function getStoragePolicies() {
return fetchApi<AdminStoragePolicy[]>('/admin/storage-policies');
}
export async function createStoragePolicy(policyData: StoragePolicyUpsertPayload) {
return fetchApi<AdminStoragePolicy>('/admin/storage-policies', {
method: 'POST',
body: JSON.stringify(policyData),
});
}
export async function updateStoragePolicy(policyId: number, policyData: StoragePolicyUpsertPayload) {
return fetchApi<AdminStoragePolicy>(`/admin/storage-policies/${policyId}`, {
method: 'PUT',
body: JSON.stringify(policyData),
});
}
export async function updateStoragePolicyStatus(policyId: number, enabled: boolean) {
return fetchApi<AdminStoragePolicy>(`/admin/storage-policies/${policyId}/status`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
});
}
export async function createStorageMigration(sourcePolicyId: number, targetPolicyId: number) {
return fetchApi('/admin/storage-policies/migrations', {
method: 'POST',
body: JSON.stringify({ sourcePolicyId, targetPolicyId }),
});
}

View File

@@ -0,0 +1,69 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminUser = {
id: number;
username: string;
email: string;
phoneNumber: string | null;
createdAt: string;
role: 'USER' | 'ADMIN';
banned: boolean;
usedStorageBytes: number;
storageQuotaBytes: number;
maxUploadSizeBytes: number;
};
export type AdminPasswordResetResponse = {
temporaryPassword: string;
};
export async function getAdminUsers(page = 0, size = 50, query = '') {
const params = new URLSearchParams({
page: String(page),
size: String(size),
query,
});
return fetchApi<PageResponse<AdminUser>>(`/admin/users?${params.toString()}`);
}
export async function updateUserRole(userId: number, role: AdminUser['role']) {
return fetchApi<AdminUser>(`/admin/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
});
}
export async function updateUserStatus(userId: number, banned: boolean) {
return fetchApi<AdminUser>(`/admin/users/${userId}/status`, {
method: 'PATCH',
body: JSON.stringify({ banned }),
});
}
export async function updateUserPassword(userId: number, newPassword: string) {
return fetchApi<AdminUser>(`/admin/users/${userId}/password`, {
method: 'PUT',
body: JSON.stringify({ newPassword }),
});
}
export async function updateUserStorageQuota(userId: number, storageQuotaBytes: number) {
return fetchApi<AdminUser>(`/admin/users/${userId}/storage-quota`, {
method: 'PATCH',
body: JSON.stringify({ storageQuotaBytes }),
});
}
export async function updateUserMaxUploadSize(userId: number, maxUploadSizeBytes: number) {
return fetchApi<AdminUser>(`/admin/users/${userId}/max-upload-size`, {
method: 'PATCH',
body: JSON.stringify({ maxUploadSizeBytes }),
});
}
export async function resetUserPassword(userId: number) {
return fetchApi<AdminPasswordResetResponse>(`/admin/users/${userId}/password/reset`, {
method: 'POST',
});
}

View File

@@ -0,0 +1,56 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminSummary = {
totalUsers: number;
totalFiles: number;
totalStorageBytes: number;
downloadTrafficBytes: number;
requestCount: number;
transferUsageBytes: number;
offlineTransferStorageBytes: number;
offlineTransferStorageLimitBytes: number;
dailyActiveUsers: Array<{
date: string;
count: number;
usernames: string[];
}>;
requestTimeline: Array<{
hour: number;
requestCount: number;
}>;
inviteCode: string;
};
export type AdminFile = {
id: number;
filename: string;
path: string;
size: number;
contentType: string;
directory: boolean;
createdAt: string;
ownerId: number;
ownerUsername: string;
ownerEmail: string;
};
export async function getAdminSummary() {
return fetchApi<AdminSummary>('/admin/summary');
}
export async function listAdminFiles(page = 0, size = 50, query = '', ownerQuery = '') {
const params = new URLSearchParams({
page: String(page),
size: String(size),
query,
ownerQuery,
});
return fetchApi<PageResponse<AdminFile>>(`/admin/files?${params.toString()}`);
}
export async function deleteAdminFile(fileId: number) {
return fetchApi<void>(`/admin/files/${fileId}`, {
method: 'DELETE',
});
}

192
front_zip/src/lib/api.ts Normal file
View File

@@ -0,0 +1,192 @@
import { clearSession, getSession, setSession, type PortalSession } from './session';
const CLIENT_HEADER = 'X-Yoyuzh-Client';
const CLIENT_ID_HEADER = 'X-Yoyuzh-Client-Id';
const CLIENT_TYPE = 'desktop';
type ApiEnvelope<T> = {
code: number;
msg: string;
data: T;
};
export class ApiError extends Error {
status: number;
code: number;
constructor(message: string, status: number, code = -1) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}
export type FetchApiOptions = RequestInit & {
auth?: boolean;
rawResponse?: boolean;
retryOnAuthFailure?: boolean;
};
export function getApiBaseUrl() {
return '/api';
}
export function getClientId() {
const storageKey = 'portal-client-id';
const existing = window.localStorage.getItem(storageKey);
if (existing) {
return existing;
}
const generated = `web-${crypto.randomUUID()}`;
window.localStorage.setItem(storageKey, generated);
return generated;
}
function buildUrl(endpoint: string) {
if (/^https?:\/\//.test(endpoint)) {
return endpoint;
}
if (endpoint.startsWith('/api/')) {
return endpoint;
}
if (endpoint.startsWith('/')) {
return `${getApiBaseUrl()}${endpoint}`;
}
return `${getApiBaseUrl()}/${endpoint}`;
}
function looksLikeQuestionMarks(message: string | null | undefined) {
if (!message) {
return true;
}
const trimmed = message.trim();
return trimmed.length === 0 || /^[?锛焆]+$/.test(trimmed);
}
function resolveFriendlyMessage(code: number, status: number, message: string) {
if ((code === 1001 || status === 401) && looksLikeQuestionMarks(message)) {
return '未登录或登录已过期,请先登录。';
}
if ((code === 1002 || status === 403) && looksLikeQuestionMarks(message)) {
return '没有权限访问该页面。';
}
if (looksLikeQuestionMarks(message)) {
return `请求失败HTTP ${status}`;
}
return message;
}
async function parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) {
if (!response.ok) {
throw new ApiError(`请求失败HTTP ${response.status}`, response.status);
}
return undefined as T;
}
const payload = (await response.json()) as ApiEnvelope<T> | T;
if (
typeof payload === 'object' &&
payload !== null &&
'code' in payload &&
'msg' in payload &&
'data' in payload
) {
const envelope = payload as ApiEnvelope<T>;
if (envelope.code !== 0) {
throw new ApiError(
resolveFriendlyMessage(envelope.code, response.status, envelope.msg),
response.status,
envelope.code,
);
}
return envelope.data;
}
if (!response.ok) {
throw new ApiError(`请求失败HTTP ${response.status}`, response.status);
}
return payload as T;
}
async function refreshAccessToken(session: PortalSession) {
const response = await fetch(buildUrl('/auth/refresh'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[CLIENT_HEADER]: CLIENT_TYPE,
[CLIENT_ID_HEADER]: getClientId(),
},
body: JSON.stringify({ refreshToken: session.refreshToken }),
});
const refreshed = await parseResponse<PortalSession>(response);
const nextSession: PortalSession = {
...session,
...refreshed,
};
setSession(nextSession);
return nextSession;
}
export async function fetchApi<T = unknown>(endpoint: string, options: FetchApiOptions = {}) {
const {
auth = true,
rawResponse = false,
retryOnAuthFailure = true,
headers,
body,
...rest
} = options;
const session = getSession();
const resolvedHeaders = new Headers(headers ?? {});
resolvedHeaders.set(CLIENT_HEADER, CLIENT_TYPE);
resolvedHeaders.set(CLIENT_ID_HEADER, getClientId());
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
if (!isFormData && body != null && !resolvedHeaders.has('Content-Type')) {
resolvedHeaders.set('Content-Type', 'application/json');
}
if (auth && session?.accessToken) {
resolvedHeaders.set('Authorization', `Bearer ${session.accessToken}`);
}
const response = await fetch(buildUrl(endpoint), {
...rest,
headers: resolvedHeaders,
body,
});
if ((response.status === 401 || response.status === 403) && auth && session?.refreshToken && retryOnAuthFailure) {
try {
const refreshed = await refreshAccessToken(session);
return fetchApi<T>(endpoint, {
...options,
retryOnAuthFailure: false,
headers: {
...(headers ?? {}),
Authorization: `Bearer ${refreshed.accessToken}`,
},
});
} catch {
clearSession();
}
}
if (rawResponse) {
if (!response.ok) {
throw new ApiError(`请求失败HTTP ${response.status}`, response.status);
}
return response as T;
}
return parseResponse<T>(response);
}

58
front_zip/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import { fetchApi } from './api';
import { clearSession, getSession, setSession, type PortalSession, type PortalUser } from './session';
type LoginPayload = {
username: string;
password: string;
};
type RegisterPayload = {
username: string;
email: string;
phoneNumber: string;
password: string;
confirmPassword: string;
inviteCode: string;
};
export async function login(payload: LoginPayload) {
const session = await fetchApi<PortalSession>('/auth/login', {
method: 'POST',
auth: false,
body: JSON.stringify(payload),
});
setSession(session);
return session;
}
export async function register(payload: RegisterPayload) {
const session = await fetchApi<PortalSession>('/auth/register', {
method: 'POST',
auth: false,
body: JSON.stringify(payload),
});
setSession(session);
return session;
}
export async function devLogin(username = 'demo') {
const session = await fetchApi<PortalSession>(`/auth/dev-login?username=${encodeURIComponent(username)}`, {
method: 'POST',
auth: false,
});
setSession(session);
return session;
}
export async function getProfile() {
const profile = await fetchApi<PortalUser>('/user/profile');
const session = getSession();
if (session) {
setSession({ ...session, user: profile });
}
return profile;
}
export function logout() {
clearSession();
}

View File

@@ -0,0 +1,46 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type BackgroundTask = {
id: number;
type: string;
status: string;
userId: number;
publicStateJson: string | null;
correlationId: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
};
export async function getTasks(page = 0, size = 50) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
return fetchApi<PageResponse<BackgroundTask>>(`/v2/tasks?${params.toString()}`);
}
export async function getTaskDetails(taskId: number) {
return fetchApi<BackgroundTask>(`/v2/tasks/${taskId}`);
}
export async function cancelTask(taskId: number) {
return fetchApi<void>(`/v2/tasks/${taskId}`, {
method: 'DELETE',
});
}
export async function retryTask(taskId: number) {
return fetchApi<BackgroundTask>(`/v2/tasks/${taskId}/retry`, {
method: 'POST',
});
}
export async function createMediaMetadataTask(fileId: number) {
return fetchApi<BackgroundTask>('/v2/tasks/media-metadata', {
method: 'POST',
body: JSON.stringify({ fileId }),
});
}

View File

@@ -0,0 +1,16 @@
export function subscribeToFileEvents(onEvent: (event: any) => void) {
const eventSource = new EventSource('/api/v2/files/events');
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onEvent(data);
} catch (error) {
console.error('Failed to parse file event:', error);
}
};
return () => {
eventSource.close();
};
}

View File

@@ -0,0 +1,3 @@
import { searchFiles } from './files';
export { searchFiles };

119
front_zip/src/lib/files.ts Normal file
View File

@@ -0,0 +1,119 @@
import { fetchApi } from './api';
export type PageResponse<T> = {
items: T[];
total: number;
page: number;
size: number;
};
export type FileItem = {
id: number;
filename: string;
path: string;
size: number;
contentType: string;
directory: boolean;
createdAt: string;
};
export type RecycleBinItem = {
id: number;
filename: string;
path: string;
size: number;
contentType: string;
directory: boolean;
createdAt: string;
deletedAt: string;
expiresAt: string;
};
export type DownloadUrlResponse = {
url: string;
};
export type LegacyShareResponse = {
token: string;
url: string;
};
export async function listFiles(path = '/', page = 0, size = 100) {
const params = new URLSearchParams({
path,
page: String(page),
size: String(size),
});
return fetchApi<PageResponse<FileItem>>(`/files/list?${params.toString()}`);
}
export async function listRecentFiles() {
return fetchApi<FileItem[]>('/files/recent');
}
export async function listRecycleBin(page = 0, size = 100) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
return fetchApi<PageResponse<RecycleBinItem>>(`/files/recycle-bin?${params.toString()}`);
}
export async function restoreRecycleBinItem(fileId: number) {
return fetchApi<FileItem>(`/files/recycle-bin/${fileId}/restore`, {
method: 'POST',
});
}
export async function deleteFile(fileId: number) {
return fetchApi<void>(`/files/${fileId}`, {
method: 'DELETE',
});
}
export async function createDirectory(path: string) {
const params = new URLSearchParams({ path });
return fetchApi<FileItem>(`/files/mkdir?${params.toString()}`, {
method: 'POST',
});
}
export async function getDownloadUrl(fileId: number) {
return fetchApi<DownloadUrlResponse>(`/files/download/${fileId}/url`);
}
export async function createLegacyShareLink(fileId: number) {
return fetchApi<LegacyShareResponse>(`/files/${fileId}/share-links`, {
method: 'POST',
});
}
export async function renameFile(fileId: number, filename: string) {
return fetchApi<FileItem>(`/files/${fileId}/rename`, {
method: 'PATCH',
body: JSON.stringify({ filename }),
});
}
export async function moveFile(fileId: number, path: string) {
return fetchApi<FileItem>(`/files/${fileId}/move`, {
method: 'PATCH',
body: JSON.stringify({ path }),
});
}
export async function copyFile(fileId: number, path: string) {
return fetchApi<FileItem>(`/files/${fileId}/copy`, {
method: 'POST',
body: JSON.stringify({ path }),
});
}
export async function searchFiles(name: string, page = 0, size = 50) {
const params = new URLSearchParams({
name,
page: String(page),
size: String(size),
});
return fetchApi<PageResponse<FileItem>>(`/v2/files/search?${params.toString()}`);
}

View File

@@ -0,0 +1,58 @@
export function formatBytes(bytes: number | null | undefined) {
if (bytes == null || Number.isNaN(bytes)) {
return '-';
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** index;
const digits = value >= 10 || index === 0 ? 0 : 1;
return `${value.toFixed(digits)} ${units[index]}`;
}
export function formatDateTime(value: string | null | undefined) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function formatDate(value: string | null | undefined) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
export function formatPercent(value: number, total: number) {
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) {
return '0%';
}
return `${Math.min(100, Math.max(0, Math.round((value / total) * 100)))}%`;
}

View File

@@ -0,0 +1,47 @@
export type PortalUser = {
id: number;
username: string;
displayName?: string | null;
email: string;
phoneNumber?: string | null;
bio?: string | null;
preferredLanguage?: string | null;
avatarUrl?: string | null;
role: 'USER' | 'ADMIN';
createdAt: string;
storageQuotaBytes: number;
maxUploadSizeBytes: number;
};
export type PortalSession = {
token?: string;
accessToken: string;
refreshToken: string;
user: PortalUser;
};
const SESSION_KEY = 'portal-session';
export function getSession() {
const raw = window.localStorage.getItem(SESSION_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as PortalSession;
} catch {
window.localStorage.removeItem(SESSION_KEY);
return null;
}
}
export function setSession(session: PortalSession) {
window.localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new CustomEvent('portal-session-changed', { detail: session }));
}
export function clearSession() {
window.localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new CustomEvent('portal-session-changed', { detail: null }));
}

View File

@@ -0,0 +1,84 @@
import { fetchApi } from './api';
import type { FileItem, PageResponse } from './files';
export type ShareItem = {
id: number;
token: string;
shareName: string | null;
ownerUsername: string;
passwordRequired: boolean;
passwordVerified: boolean;
allowImport: boolean;
allowDownload: boolean;
maxDownloads: number | null;
downloadCount: number;
viewCount: number;
expiresAt: string | null;
createdAt: string;
file: FileItem;
};
export type CreateSharePayload = {
fileId: number;
password?: string;
expiresAt?: string | null;
maxDownloads?: number | null;
allowImport?: boolean;
allowDownload?: boolean;
shareName?: string;
};
export async function getMyShares(page = 0, size = 50) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
return fetchApi<PageResponse<ShareItem>>(`/v2/shares/mine?${params.toString()}`);
}
export async function createShare(payload: CreateSharePayload) {
return fetchApi<ShareItem>('/v2/shares', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export async function deleteShare(shareId: number) {
return fetchApi<void>(`/v2/shares/${shareId}`, {
method: 'DELETE',
});
}
export async function getShareDetails(token: string) {
return fetchApi<ShareItem>(`/v2/shares/${token}`, {
auth: false,
});
}
export async function verifySharePassword(token: string, password: string) {
return fetchApi<ShareItem>(`/v2/shares/${token}/verify-password`, {
method: 'POST',
auth: false,
body: JSON.stringify({ password }),
});
}
export async function importShare(token: string, path: string, password?: string) {
return fetchApi<FileItem>(`/v2/shares/${token}/import`, {
method: 'POST',
body: JSON.stringify({ path, password }),
});
}
export function buildSharePublicUrl(token: string) {
return `${window.location.origin}/share/${token}`;
}
export function buildShareDownloadUrl(token: string, password?: string) {
const url = new URL(`/api/v2/shares/${token}`, window.location.origin);
url.searchParams.set('download', '1');
if (password) {
url.searchParams.set('password', password);
}
return url.toString();
}

View File

@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildOfflineTransferDownloadUrl,
sanitizePickupCode,
toTransferFilePayload,
} from './transfer';
test('sanitizePickupCode keeps only uppercase letters and digits', () => {
assert.equal(sanitizePickupCode(' ab-12 cD*3 '), 'AB12CD');
});
test('toTransferFilePayload preserves relative paths and content types', () => {
const file = new File(['hello'], 'greeting.txt', {
type: 'text/plain',
lastModified: 1710000000000,
});
Object.defineProperty(file, 'webkitRelativePath', {
value: 'docs/greeting.txt',
configurable: true,
});
assert.deepEqual(toTransferFilePayload([file]), [
{
name: 'greeting.txt',
relativePath: 'docs/greeting.txt',
size: 5,
contentType: 'text/plain',
},
]);
});
test('buildOfflineTransferDownloadUrl uses the local api base', () => {
assert.equal(
buildOfflineTransferDownloadUrl('session-1', 'file-1'),
'/api/transfer/sessions/session-1/files/file-1/download',
);
});

View File

@@ -0,0 +1,113 @@
import { fetchApi, getApiBaseUrl } from './api';
export type TransferMode = 'ONLINE' | 'OFFLINE';
export type TransferFilePayload = {
name: string;
relativePath: string;
size: number;
contentType: string;
};
export type TransferFileItem = TransferFilePayload & {
id?: string | null;
uploaded?: boolean | null;
};
export type TransferSessionResponse = {
sessionId: string;
pickupCode: string;
mode: TransferMode;
expiresAt: string;
files: TransferFileItem[];
};
export type LookupTransferSessionResponse = {
sessionId: string;
pickupCode: string;
mode: TransferMode;
expiresAt: string;
};
export function sanitizePickupCode(value: string) {
return value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 6);
}
export function getTransferFileRelativePath(file: File) {
const rawRelativePath =
'webkitRelativePath' in file && typeof file.webkitRelativePath === 'string' && file.webkitRelativePath
? file.webkitRelativePath
: file.name;
const normalizedPath = rawRelativePath
.replaceAll('\\', '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean)
.join('/');
return normalizedPath || file.name;
}
export function toTransferFilePayload(files: File[]) {
return files.map<TransferFilePayload>((file) => ({
name: file.name,
relativePath: getTransferFileRelativePath(file),
size: file.size,
contentType: file.type || 'application/octet-stream',
}));
}
export function createTransferSession(files: File[], mode: TransferMode) {
return fetchApi<TransferSessionResponse>('/transfer/sessions', {
method: 'POST',
body: JSON.stringify({
mode,
files: toTransferFilePayload(files),
}),
});
}
export function lookupTransferSession(pickupCode: string) {
return fetchApi<LookupTransferSessionResponse>(
`/transfer/sessions/lookup?pickupCode=${encodeURIComponent(sanitizePickupCode(pickupCode))}`,
{
auth: false,
},
);
}
export function joinTransferSession(sessionId: string) {
return fetchApi<TransferSessionResponse>(`/transfer/sessions/${encodeURIComponent(sessionId)}/join`, {
method: 'POST',
auth: false,
});
}
export function listMyOfflineTransferSessions() {
return fetchApi<TransferSessionResponse[]>('/transfer/sessions/offline/mine');
}
export function uploadOfflineTransferFile(sessionId: string, fileId: string, file: File) {
const body = new FormData();
body.append('file', file);
return fetchApi<void>(`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/content`, {
method: 'POST',
body,
});
}
export function buildOfflineTransferDownloadUrl(sessionId: string, fileId: string) {
return `${getApiBaseUrl()}/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/download`;
}
export function importOfflineTransferFile(sessionId: string, fileId: string, path: string) {
return fetchApi<{ id: number; filename: string; path: string }>(
`/transfer/sessions/${encodeURIComponent(sessionId)}/files/${encodeURIComponent(fileId)}/import`,
{
method: 'POST',
body: JSON.stringify({ path }),
},
);
}

View File

@@ -0,0 +1,131 @@
import { fetchApi } from './api';
export type UploadSessionStrategy = {
prepareUrl: string | null;
proxyUploadUrl: string | null;
preparePartUrlTemplate: string | null;
recordPartUrlTemplate: string | null;
completeUrl: string;
proxyFieldName: string | null;
};
export type UploadSession = {
sessionId: string;
objectKey: string;
directUpload: boolean;
multipartUpload: boolean;
uploadMode: 'PROXY' | 'DIRECT_SINGLE' | 'DIRECT_MULTIPART';
path: string;
filename: string;
contentType: string;
size: number;
storagePolicyId: number | null;
status: string;
chunkSize: number;
chunkCount: number;
expiresAt: string;
createdAt: string;
updatedAt: string;
strategy: UploadSessionStrategy;
};
export type PreparedUpload = {
direct: boolean;
uploadUrl: string;
method: string;
headers: Record<string, string>;
storageName: string;
};
function buildPartUrl(template: string, partIndex: number) {
return template.replace('{partIndex}', String(partIndex));
}
export async function createUploadSession(fileInfo: {
path: string;
filename: string;
contentType: string;
size: number;
}) {
return fetchApi<UploadSession>('/v2/files/upload-sessions', {
method: 'POST',
body: JSON.stringify(fileInfo),
});
}
export async function getUploadSession(sessionId: string) {
return fetchApi<UploadSession>(`/v2/files/upload-sessions/${sessionId}`);
}
export async function cancelUploadSession(sessionId: string) {
return fetchApi<UploadSession>(`/v2/files/upload-sessions/${sessionId}`, {
method: 'DELETE',
});
}
export async function prepareUpload(sessionId: string) {
return fetchApi<PreparedUpload>(`/v2/files/upload-sessions/${sessionId}/prepare`);
}
export async function prepareUploadPart(sessionId: string, partIndex: number) {
return fetchApi<PreparedUpload>(`/v2/files/upload-sessions/${sessionId}/parts/${partIndex}/prepare`);
}
export async function recordUploadedPart(sessionId: string, partIndex: number, etag: string, size: number) {
return fetchApi<UploadSession>(`/v2/files/upload-sessions/${sessionId}/parts/${partIndex}`, {
method: 'PUT',
body: JSON.stringify({ etag, size }),
});
}
export async function completeUploadSession(sessionId: string) {
return fetchApi<UploadSession>(`/v2/files/upload-sessions/${sessionId}/complete`, {
method: 'POST',
});
}
export async function uploadFileWithSession(file: File, path = '/') {
const session = await createUploadSession({
path,
filename: file.name,
contentType: file.type || 'application/octet-stream',
size: file.size,
});
if (session.uploadMode === 'PROXY') {
const formData = new FormData();
formData.append(session.strategy.proxyFieldName || 'file', file);
await fetchApi(session.strategy.proxyUploadUrl || `/v2/files/upload-sessions/${session.sessionId}/content`, {
method: 'POST',
body: formData,
});
return getUploadSession(session.sessionId);
}
if (session.uploadMode === 'DIRECT_SINGLE') {
const prepared = await prepareUpload(session.sessionId);
await fetch(prepared.uploadUrl, {
method: prepared.method || 'PUT',
headers: prepared.headers,
body: file,
});
return completeUploadSession(session.sessionId);
}
const chunkSize = Math.max(session.chunkSize || 5 * 1024 * 1024, 5 * 1024 * 1024);
for (let partIndex = 0; partIndex < session.chunkCount; partIndex += 1) {
const start = partIndex * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunk = file.slice(start, end);
const prepared = await prepareUploadPart(session.sessionId, partIndex);
const uploadResponse = await fetch(prepared.uploadUrl, {
method: prepared.method || 'PUT',
headers: prepared.headers,
body: chunk,
});
const etag = uploadResponse.headers.get('etag') ?? '';
await recordUploadedPart(session.sessionId, partIndex, etag.replaceAll('"', ''), chunk.size);
}
return completeUploadSession(session.sessionId);
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

13
front_zip/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import {ThemeProvider} from './components/ThemeProvider.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { HardDrive, LayoutDashboard, ListTodo, LogOut, Send, Share2, Trash2 } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { ThemeToggle } from '@/src/components/ThemeToggle';
import { logout } from '@/src/lib/auth';
import { getSession, type PortalSession } from '@/src/lib/session';
export default function MobileLayout() {
const navigate = useNavigate();
const [session, setSession] = useState<PortalSession | null>(() => getSession());
useEffect(() => {
const handleSessionChange = (event: Event) => {
const customEvent = event as CustomEvent<PortalSession | null>;
setSession(customEvent.detail ?? getSession());
};
window.addEventListener('portal-session-changed', handleSessionChange);
return () => window.removeEventListener('portal-session-changed', handleSessionChange);
}, []);
useEffect(() => {
if (!session) {
navigate('/login', { replace: true });
}
}, [navigate, session]);
const navItems = [
{ to: '/overview', icon: LayoutDashboard, label: '概览' },
{ to: '/files', icon: HardDrive, label: '网盘' },
{ to: '/tasks', icon: ListTodo, label: '任务' },
{ to: '/shares', icon: Share2, label: '分享' },
{ to: '/recycle-bin', icon: Trash2, label: '回收站' },
{ to: '/transfer', icon: Send, label: '快传' },
];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-screen w-full flex-col overflow-hidden bg-aurora text-gray-900 dark:text-gray-100 transition-colors"
>
<header className="fixed top-4 left-4 right-4 z-50 flex items-center justify-between glass-panel rounded-lg px-6 py-4 shadow-xl border-white/20">
<div>
<div className="text-sm font-black tracking-tight text-blue-600 dark:text-blue-400 uppercase"></div>
<div className="text-sm font-bold opacity-80 dark:opacity-90 uppercase tracking-[0.2em]">{session?.user.username || '游客用户'}</div>
</div>
<div className="flex items-center gap-3">
<ThemeToggle />
<button
type="button"
onClick={() => {
logout();
navigate('/login');
}}
className="rounded-lg p-2.5 glass-panel hover:bg-red-500/10 text-gray-700 dark:text-gray-200 hover:text-red-500 transition-all border-white/10"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
<main className="relative flex-1 overflow-y-auto pt-28 pb-28 px-4">
<Outlet />
</main>
<nav className="fixed bottom-6 left-4 right-4 z-50 flex h-20 items-center justify-around glass-panel rounded-lg px-4 shadow-2xl border-white/20">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex flex-col items-center justify-center gap-1.5 transition-all duration-300 relative',
isActive ? 'scale-105' : 'opacity-70 grayscale hover:opacity-100 hover:grayscale-0',
)
}
>
{({ isActive }) => (
<>
<div className={cn(
'p-2.5 rounded-lg transition-all',
isActive ? 'bg-blue-600 text-white shadow-blue-500/30 shadow-lg' : 'text-gray-900 dark:text-gray-100'
)}>
<item.icon className="h-4.5 w-4.5" />
</div>
<span className={cn(
'text-xs font-black uppercase tracking-[0.1em]',
isActive ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-200'
)}>
{item.label}
</span>
</>
)}
</NavLink>
))}
</nav>
</motion.div>
);
}

View File

@@ -0,0 +1,228 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Copy, Download, Key, ShieldCheck, Clock, Activity, FileText, Folder, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { buildShareDownloadUrl, getShareDetails, importShare, verifySharePassword, type ShareItem } from '@/src/lib/shares-v2';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { getSession } from '@/src/lib/session';
export default function FileShare() {
const navigate = useNavigate();
const { token = '' } = useParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [share, setShare] = useState<ShareItem | null>(null);
useEffect(() => {
let cancelled = false;
async function loadShare() {
setLoading(true);
setError('');
try {
const result = await getShareDetails(token);
if (!cancelled) {
setShare(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '加载分享失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
if (token) {
void loadShare();
}
return () => {
cancelled = true;
};
}, [token]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black">
<div className="flex flex-col items-center gap-4">
<div className="h-1 w-48 bg-white/5 rounded-full overflow-hidden">
<motion.div
initial={{ x: '-100%' }}
animate={{ x: '100%' }}
transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}
className="h-full w-full bg-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)]"
/>
</div>
<span className="text-[10px] font-black tracking-[0.3em] uppercase opacity-20"></span>
</div>
</div>
);
}
if (!share) {
return (
<div className="flex min-h-screen items-center justify-center bg-black p-4">
<div className="glass-panel-no-hover p-10 rounded-lg border border-red-500/20 max-w-md text-center">
<div className="text-red-500 font-black text-[10px] tracking-[0.3em] uppercase mb-4">访</div>
<div className="text-gray-900 dark:text-white font-black tracking-tight">{error || '分享不存在'}</div>
</div>
</div>
);
}
const needsPassword = share.passwordRequired && !share.passwordVerified;
return (
<div className="flex min-h-screen items-center justify-center bg-aurora px-4 py-12 text-gray-900 dark:text-gray-100 transition-colors overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={needsPassword ? 'auth' : 'content'}
initial={{ opacity: 0, y: 20, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.98 }}
className="w-full max-w-xl glass-panel-no-hover rounded-lg p-12 shadow-3xl border border-white/10 relative overflow-hidden"
>
{/* Decorative scanner line */}
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-blue-500 to-transparent opacity-30 animate-scan"></div>
<div className="relative z-10">
<header className="mb-12 text-center">
<div className="inline-flex items-center gap-3 text-[9px] font-black uppercase tracking-[0.4em] mb-6 px-4 py-2 rounded-lg bg-blue-500/10 text-blue-500 border border-blue-500/20 shadow-inner">
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<h1 className="break-all text-4xl font-black tracking-tight mb-4 text-gray-900 dark:text-white drop-shadow-2xl">{share.shareName || share.file.filename}</h1>
<div className="flex flex-wrap items-center justify-center gap-3">
<span className="text-[10px] font-black uppercase tracking-widest bg-white/5 px-3 py-1.5 rounded-lg border border-white/5 opacity-60">
{share.ownerUsername}
</span>
<span className="text-[10px] font-black uppercase tracking-widest bg-blue-500/20 px-3 py-1.5 rounded-lg border border-blue-500/20 text-blue-400">
{share.file.directory ? '目录' : `文件大小 / ${formatBytes(share.file.size)}`}
</span>
</div>
</header>
{error ? (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="mb-10 rounded-lg bg-red-500/10 border border-red-500/20 px-8 py-5 text-xs text-red-500 font-bold uppercase tracking-widest backdrop-blur-md"
>
{error}
</motion.div>
) : null}
{needsPassword ? (
<form
className="space-y-8"
onSubmit={async (event) => {
event.preventDefault();
setError('');
try {
const verified = await verifySharePassword(token, password);
setShare(verified);
} catch (err) {
setError(err instanceof Error ? err.message : '校验密码失败');
}
}}
>
<div className="space-y-3">
<label className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30 ml-2">访</label>
<div className="relative">
<Key className="pointer-events-none absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-blue-500 opacity-60" />
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
className="w-full rounded-lg glass-panel bg-white/5 py-6 pl-14 pr-6 outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 transition-all font-black tracking-[0.5em] text-lg placeholder:tracking-widest placeholder:opacity-10 text-gray-900 dark:text-white"
required
/>
</div>
</div>
<button
type="submit"
className="w-full rounded-lg bg-blue-600 py-6 text-[12px] font-black uppercase tracking-[0.3em] text-white shadow-[0_10px_30px_rgba(37,99,235,0.3)] hover:bg-blue-500 hover:scale-[1.01] active:scale-[0.99] transition-all flex items-center justify-center gap-3"
>
<ChevronRight className="h-4 w-4" />
</button>
</form>
) : (
<div className="space-y-10">
<div className="grid grid-cols-1 gap-1 rounded-lg bg-black/40 p-8 border border-white/10 shadow-inner">
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-widest py-3">
<span className="opacity-30 flex items-center gap-2"><Clock className="h-3 w-3" /> </span>
<span className="text-gray-700 dark:text-white/80">{formatDateTime(share.createdAt)}</span>
</div>
<div className="h-[1px] w-full bg-white/5"></div>
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-widest py-3">
<span className="opacity-30 flex items-center gap-2"><Activity className="h-3 w-3" /> </span>
<span className={cn("font-black", share.expiresAt ? "text-amber-500/80" : "text-green-500/80")}>
{share.expiresAt ? `截止:${formatDateTime(share.expiresAt)}` : '永久有效'}
</span>
</div>
<div className="h-[1px] w-full bg-white/5"></div>
<div className="flex justify-between items-center text-[10px] font-black uppercase tracking-widest py-3">
<span className="opacity-30 flex items-center gap-2"><Download className="h-3 w-3" /> </span>
<span className="text-gray-700 dark:text-white/80">
{share.downloadCount}
{share.maxDownloads ? ` / 上限 ${share.maxDownloads}` : ''}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{share.allowDownload ? (
<button
type="button"
onClick={() => window.open(buildShareDownloadUrl(token, password || undefined), '_blank', 'noopener,noreferrer')}
className="flex items-center justify-center gap-4 rounded-lg bg-blue-600 py-6 text-[11px] font-black uppercase tracking-[0.2em] text-white shadow-[0_10px_30px_rgba(37,99,235,0.3)] hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all group"
>
<Download className="h-5 w-5 group-hover:translate-y-1 transition-transform" />
</button>
) : null}
{share.allowImport ? (
<button
type="button"
onClick={async () => {
if (!getSession()) {
navigate('/login');
return;
}
const path = window.prompt('请输入保存目录:', '/') || '/';
try {
await importShare(token, path, password || undefined);
window.alert('已导入到网盘');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
}}
className="flex items-center justify-center gap-4 rounded-lg glass-panel py-6 text-[11px] font-black uppercase tracking-[0.2em] hover:bg-white/10 border border-white/10 hover:scale-[1.02] active:scale-[0.98] transition-all group"
>
<Copy className="h-5 w-5 group-hover:scale-110 transition-transform" />
</button>
) : null}
</div>
</div>
)}
</div>
{/* Version/System stamp */}
<div className="mt-12 pt-8 border-t border-white/5 flex items-center justify-between opacity-20 text-[8px] font-black uppercase tracking-[0.4em]">
<span> v4.0.21</span>
<span>::TC-99</span>
</div>
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useState, type FormEvent } from 'react';
import { Moon, Sun } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { useTheme } from '@/src/components/ThemeProvider';
import { devLogin, login, register } from '@/src/lib/auth';
import { cn } from '@/src/lib/utils';
type LoginFormState = {
username: string;
password: string;
};
type RegisterFormState = {
username: string;
email: string;
phoneNumber: string;
password: string;
confirmPassword: string;
inviteCode: string;
};
const emptyRegisterForm: RegisterFormState = {
username: '',
email: '',
phoneNumber: '',
password: '',
confirmPassword: '',
inviteCode: '',
};
export default function Login() {
const navigate = useNavigate();
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [loginForm, setLoginForm] = useState<LoginFormState>({ username: '', password: '' });
const [registerForm, setRegisterForm] = useState<RegisterFormState>(emptyRegisterForm);
async function handleLoginSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError('');
try {
const session = await login(loginForm);
navigate(session.user.role === 'ADMIN' ? '/admin/dashboard' : '/overview');
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
} finally {
setLoading(false);
}
}
async function handleRegisterSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError('');
try {
const session = await register(registerForm);
navigate(session.user.role === 'ADMIN' ? '/admin/dashboard' : '/overview');
} catch (err) {
setError(err instanceof Error ? err.message : '注册失败');
} finally {
setLoading(false);
}
}
async function handleDevLogin(username: string) {
setLoading(true);
setError('');
try {
const session = await devLogin(username);
navigate(session.user.role === 'ADMIN' ? '/admin/dashboard' : '/overview');
} catch (err) {
setError(err instanceof Error ? err.message : '开发登录失败');
} finally {
setLoading(false);
}
}
const { theme, setTheme } = useTheme();
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="min-h-screen bg-aurora flex flex-col justify-center py-12 px-6 lg:px-8 relative overflow-hidden"
>
{/* Theme Toggle Top Right */}
<motion.div
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5 }}
className="absolute top-6 right-6"
>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-3 rounded-lg glass-panel-no-hover hover:bg-white/40 dark:hover:bg-black/40 transition-all shadow-lg"
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-yellow-300" /> : <Moon className="w-5 h-5 text-gray-700" />}
</button>
</motion.div>
<div className="sm:mx-auto sm:w-full sm:max-w-md relative z-10">
<motion.div
layout
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="glass-panel-no-hover py-12 px-8 shadow-2xl rounded-lg sm:px-12 border-white/20 dark:border-white/10"
>
<div className="mb-10 text-center">
<motion.h2
className="text-4xl font-black tracking-tight animate-text-reveal"
style={{ background: 'linear-gradient(to right, currentColor, #3b82f6)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}
>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ delay: 0.4 }}
className="mt-3 text-xs font-bold uppercase tracking-widest"
>
{isLogin ? '登录认证' : '创建账号'}
</motion.p>
</div>
{error ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
className="mb-6 rounded-lg bg-red-500/10 px-4 py-3 text-[13px] text-red-600 dark:text-red-400 font-bold border border-red-500/20 backdrop-blur-md overflow-hidden"
>
{error}
</motion.div>
) : null}
<motion.div layout>
{isLogin ? (
<form className="space-y-4" onSubmit={handleLoginSubmit}>
<motion.div initial={{ x: -10, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.1 }}>
<input
placeholder="用户名"
value={loginForm.username}
onChange={(event) => setLoginForm((current) => ({ ...current, username: event.target.value }))}
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/30 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
required
/>
</motion.div>
<motion.div initial={{ x: -10, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.2 }}>
<input
type="password"
placeholder="密码"
value={loginForm.password}
onChange={(event) => setLoginForm((current) => ({ ...current, password: event.target.value }))}
className="w-full px-5 py-4 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/30 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
required
/>
</motion.div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="submit"
disabled={loading}
className="w-full flex justify-center mt-6 py-4 px-4 rounded-lg shadow-lg text-xs font-black uppercase tracking-widest text-white bg-blue-600 hover:bg-blue-500 transition-all disabled:opacity-50"
>
{loading ? '处理中...' : '登录'}
</motion.button>
</form>
) : (
<form className="space-y-4" onSubmit={handleRegisterSubmit}>
{[
{ name: 'username', placeholder: '用户名', type: 'text' },
{ name: 'email', placeholder: '邮箱地址', type: 'email' },
{ name: 'phoneNumber', placeholder: '手机号', type: 'text' },
{ name: 'inviteCode', placeholder: '邀请码', type: 'text' },
{ name: 'password', placeholder: '密码', type: 'password' },
{ name: 'confirmPassword', placeholder: '确认密码', type: 'password' },
].map((field, idx) => (
<motion.div
key={field.name}
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: idx * 0.05 }}
>
<input
type={field.type}
placeholder={field.placeholder}
value={registerForm[field.name as keyof RegisterFormState]}
onChange={(event) => setRegisterForm((current) => ({ ...current, [field.name]: event.target.value }))}
className="w-full px-5 py-3.5 bg-white/10 dark:bg-black/20 border border-white/10 dark:border-white/5 rounded-lg placeholder-white/20 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 transition-all font-bold tracking-wide"
required
/>
</motion.div>
))}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
type="submit"
disabled={loading}
className="w-full flex justify-center mt-6 py-4 px-4 rounded-lg shadow-lg text-xs font-black uppercase tracking-widest text-white bg-blue-600 hover:bg-blue-500 transition-all disabled:opacity-50"
>
{loading ? '创建中...' : '注册账号'}
</motion.button>
</form>
)}
</motion.div>
<div className="mt-8 space-y-4">
<button
type="button"
onClick={() => setIsLogin((current) => !current)}
className="w-full py-4 text-[10px] font-black uppercase tracking-widest opacity-40 hover:opacity-100 transition-opacity"
>
{isLogin ? '还没有账号?去注册' : '已有账号?去登录'}
</button>
<button
type="button"
onClick={() => navigate('/transfer')}
className="w-full py-4 rounded-lg glass-panel border border-white/10 text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400 hover:bg-white/20 transition-all"
>
</button>
<div className="flex justify-center gap-8 pt-4 border-t border-white/10">
<button
onClick={() => handleDevLogin('demo')}
disabled={loading}
className="text-[10px] font-black uppercase tracking-widest text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-50"
>
</button>
<button
onClick={() => handleDevLogin('admin')}
disabled={loading}
className="text-[10px] font-black uppercase tracking-widest text-purple-500 hover:text-purple-400 transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
</motion.div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useState } from 'react';
import { HardDrive, ListTodo, Send, Share2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { motion } from 'motion/react';
import { getProfile } from '@/src/lib/auth';
import { getTasks, type BackgroundTask } from '@/src/lib/background-tasks';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { listRecentFiles, type FileItem } from '@/src/lib/files';
import { getSession } from '@/src/lib/session';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.1
}
}
};
const item = {
hidden: { y: 20, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function Overview() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [recentFiles, setRecentFiles] = useState<FileItem[]>([]);
const [recentTasks, setRecentTasks] = useState<BackgroundTask[]>([]);
const [profile, setProfile] = useState(() => getSession()?.user ?? null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError('');
try {
const [nextProfile, files, tasksPage] = await Promise.all([getProfile(), listRecentFiles(), getTasks(0, 5)]);
if (!cancelled) {
setProfile(nextProfile);
setRecentFiles(files);
setRecentTasks(tasksPage.items.slice(0, 5));
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : '加载概览失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col h-full overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="flex items-center gap-4 mb-4">
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
</div>
<p className="mb-10 text-[11px] font-black uppercase tracking-[0.2em] opacity-40">
{profile ? `${profile.displayName || profile.username} / 在线` : '正在初始化会话...'}
</p>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 px-6 py-4 text-sm text-red-600 dark:text-red-400 font-bold border border-red-500/20 backdrop-blur-md">{error}</div> : null}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="mb-10 grid grid-cols-1 md:grid-cols-3 gap-6"
>
<motion.div variants={item} className="glass-panel p-8 flex flex-col justify-center gap-3">
<div className="text-[10px] font-black uppercase tracking-widest opacity-40"></div>
<div className="text-2xl font-black tracking-tight">{profile?.role === 'ADMIN' ? '管理员' : '普通用户'}</div>
</motion.div>
<motion.div variants={item} className="glass-panel p-8 flex flex-col justify-center gap-3">
<div className="text-[10px] font-black uppercase tracking-widest opacity-40"></div>
<div className="text-2xl font-black tracking-tight">{formatBytes(profile?.storageQuotaBytes ?? 0)}</div>
</motion.div>
<motion.div variants={item} className="glass-panel p-8 flex flex-col justify-center gap-3">
<div className="text-[10px] font-black uppercase tracking-widest opacity-40"></div>
<div className="text-2xl font-black tracking-tight">{formatBytes(profile?.maxUploadSizeBytes ?? 0)}</div>
</motion.div>
</motion.div>
<h2 className="mb-6 text-sm font-black uppercase tracking-widest opacity-60"></h2>
<motion.div
variants={container}
initial="hidden"
animate="show"
className="mb-12 grid grid-cols-2 md:grid-cols-4 gap-4"
>
<motion.div variants={item}>
<Link to="/files" className="glass-panel p-6 flex flex-col items-center justify-center font-black group transition-all duration-300 border-white/10">
<HardDrive className="mb-4 h-6 w-6 text-blue-500 group-hover:scale-110 transition-transform" />
<div className="text-xs tracking-widest"></div>
<div className="mt-1 text-[9px] opacity-40 font-bold uppercase tracking-tighter"></div>
</Link>
</motion.div>
<motion.div variants={item}>
<Link to="/tasks" className="glass-panel p-6 flex flex-col items-center justify-center font-black group transition-all duration-300 border-white/10">
<ListTodo className="mb-4 h-6 w-6 text-amber-500 group-hover:scale-110 transition-transform" />
<div className="text-xs tracking-widest"></div>
<div className="mt-1 text-[9px] opacity-40 font-bold uppercase tracking-tighter"></div>
</Link>
</motion.div>
<motion.div variants={item}>
<Link to="/shares" className="glass-panel p-6 flex flex-col items-center justify-center font-black group transition-all duration-300 border-white/10">
<Share2 className="mb-4 h-6 w-6 text-rose-500 group-hover:scale-110 transition-transform" />
<div className="text-xs tracking-widest"></div>
<div className="mt-1 text-[9px] opacity-40 font-bold uppercase tracking-tighter"></div>
</Link>
</motion.div>
<motion.div variants={item}>
<Link to="/transfer" className="glass-panel p-6 flex flex-col items-center justify-center font-black group transition-all duration-300 border-white/10">
<Send className="mb-4 h-6 w-6 text-green-500 group-hover:scale-110 transition-transform" />
<div className="text-xs tracking-widest"></div>
<div className="mt-1 text-[9px] opacity-40 font-bold uppercase tracking-tighter"></div>
</Link>
</motion.div>
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.5 }}
className="glass-panel-no-hover shadow-sm flex flex-col"
>
<div className="border-b border-white/10 px-8 py-6">
<h2 className="text-xs font-black uppercase tracking-widest"></h2>
</div>
<div className="p-6">
{loading ? (
<div className="text-xs font-bold opacity-40 uppercase tracking-widest p-4">...</div>
) : recentFiles.length === 0 ? (
<div className="text-xs font-bold opacity-40 uppercase tracking-widest p-4 text-center"></div>
) : (
<div className="space-y-2">
{recentFiles.map((file) => (
<div key={file.id} className="flex items-center justify-between bg-white/5 dark:bg-black/20 rounded-lg px-5 py-4 hover:bg-white/10 dark:hover:bg-white/5 transition-all group">
<div className="min-w-0 pr-4">
<div className="truncate text-sm font-bold tracking-tight group-hover:text-blue-500 transition-colors uppercase">{file.filename}</div>
<div className="truncate text-[10px] font-bold opacity-30 mt-1 uppercase tracking-tighter">{file.path}</div>
</div>
<div className="text-right text-[10px] font-black opacity-40 flex-shrink-0 tracking-tighter">
<div>{formatBytes(file.size)}</div>
<div className="mt-1">{formatDateTime(file.createdAt)}</div>
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
<motion.div
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.6 }}
className="glass-panel-no-hover shadow-sm flex flex-col"
>
<div className="border-b border-white/10 px-8 py-6">
<h2 className="text-xs font-black uppercase tracking-widest"></h2>
</div>
<div className="p-6">
{loading ? (
<div className="text-xs font-bold opacity-40 uppercase tracking-widest p-4">...</div>
) : recentTasks.length === 0 ? (
<div className="text-xs font-bold opacity-40 uppercase tracking-widest p-4 text-center"></div>
) : (
<div className="space-y-4">
{recentTasks.map((task) => (
<div key={task.id} className="border-b border-white/5 dark:border-white/5 pb-4 last:border-0 last:pb-0">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-black tracking-widest uppercase">{task.type}</div>
<div className="text-[9px] font-black px-2 py-0.5 bg-blue-500/10 text-blue-500 rounded-sm border border-blue-500/20 uppercase">{task.status}</div>
</div>
<div className="text-[10px] font-bold opacity-30 tracking-tighter">{formatDateTime(task.updatedAt)}</div>
</div>
))}
</div>
)}
</div>
</motion.div>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { listRecycleBin, restoreRecycleBinItem, type RecycleBinItem } from '@/src/lib/files';
import { cn } from '@/src/lib/utils';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function RecycleBin() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [items, setItems] = useState<RecycleBinItem[]>([]);
async function loadItems() {
setError('');
try {
const result = await listRecycleBin(0, 100);
setItems(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载回收站失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadItems();
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10">
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70"> / </p>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70">...</div>
) : items.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70"></div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-right text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{items.map((item) => (
<motion.tr
key={item.id}
variants={itemVariants}
className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group"
>
<td className="px-8 py-5 text-[13px] font-black tracking-tight uppercase">{item.filename}</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tight uppercase truncate max-w-[150px]">{item.path}</td>
<td className="px-8 py-5 text-[10px] font-black opacity-50 tracking-tighter">{item.directory ? '目录' : formatBytes(item.size)}</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tighter uppercase">{formatDateTime(item.deletedAt)}</td>
<td className="px-8 py-5 text-[10px] font-black text-amber-500 uppercase tracking-tighter">{formatDateTime(item.expiresAt)}</td>
<td className="px-8 py-5 text-right">
<button
type="button"
onClick={async () => {
await restoreRecycleBinItem(item.id);
await loadItems();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="恢复"
>
<RotateCcw className="h-4 w-4" />
</button>
</td>
</motion.tr>
))}
</motion.tbody>
</table>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { ExternalLink, Key, Trash2 } from 'lucide-react';
import { motion } from 'motion/react';
import { buildSharePublicUrl, deleteShare, getMyShares, type ShareItem } from '@/src/lib/shares-v2';
import { formatDateTime } from '@/src/lib/format';
import { cn } from '@/src/lib/utils';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function Shares() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [shares, setShares] = useState<ShareItem[]>([]);
async function loadShares() {
setError('');
try {
const result = await getMyShares(0, 100);
setShares(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载分享失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadShares();
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="flex items-center justify-between mb-10">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70"> / 访</p>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70">...</div>
) : shares.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70"></div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-right text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{shares.map((share) => (
<motion.tr key={share.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5">
<div className="font-black text-[13px] tracking-tight uppercase">{share.shareName || share.file.filename}</div>
<div className="mt-1 text-sm opacity-80 dark:opacity-90 font-bold uppercase tracking-tighter truncate max-w-[200px]">{share.file.path}</div>
</td>
<td className="px-8 py-5 text-xs">
<div className="flex flex-wrap items-center gap-2">
{share.passwordRequired ? (
<span className="flex items-center gap-1.5 px-2 py-0.5 rounded-sm bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[8px] font-black border border-amber-500/20 uppercase tracking-widest">
<Key className="h-2.5 w-2.5" />
</span>
) : null}
<span className="px-2 py-0.5 rounded-sm bg-blue-500/10 text-blue-600 dark:text-blue-400 text-[8px] font-black border border-blue-500/20 uppercase tracking-widest">
{share.allowDownload ? '可下载' : '仅查看'}
</span>
<span className="px-2 py-0.5 rounded-sm bg-purple-500/10 text-purple-600 dark:text-purple-400 text-[8px] font-black border border-purple-500/20 uppercase tracking-widest">
{share.allowImport ? '可导入' : '受保护'}
</span>
</div>
</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tighter uppercase">{share.expiresAt ? formatDateTime(share.expiresAt) : '永久有效'}</td>
<td className="px-8 py-5 text-sm font-black tracking-tighter uppercase">
<div className="text-blue-500">DL::{share.downloadCount}</div>
<div className="opacity-80 dark:opacity-90">VW::{share.viewCount}</div>
</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={() => window.open(buildSharePublicUrl(share.token), '_blank', 'noopener,noreferrer')}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10 shadow-sm"
title="打开链接"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
type="button"
onClick={async () => {
if (!window.confirm('确认删除这个分享吗?')) return;
await deleteShare(share.id);
await loadShares();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-red-500 hover:text-white text-red-500 transition-all border-white/10 shadow-sm"
title="删除分享"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</motion.tr>
))}
</motion.tbody>
</table>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,227 @@
import { useEffect, useMemo, useState } from 'react';
import { RefreshCw, RotateCcw, Square, CheckCircle2, Clock, Play, AlertCircle } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { cancelTask, getTasks, retryTask, type BackgroundTask } from '@/src/lib/background-tasks';
import { formatDateTime } from '@/src/lib/format';
function parseTaskState(task: BackgroundTask) {
if (!task.publicStateJson) {
return {};
}
try {
return JSON.parse(task.publicStateJson) as Record<string, unknown>;
} catch {
return {};
}
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function Tasks() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(true);
const [tasks, setTasks] = useState<BackgroundTask[]>([]);
async function loadTasks() {
setError('');
try {
const result = await getTasks(0, 100);
setTasks(result.items);
} catch (err) {
setError(err instanceof Error ? err.message : '加载任务失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadTasks();
}, []);
useEffect(() => {
if (!autoRefresh) {
return undefined;
}
const timer = window.setInterval(() => {
void loadTasks();
}, 5000);
return () => window.clearInterval(timer);
}, [autoRefresh]);
const rows = useMemo(
() =>
tasks.map((task) => {
const state = parseTaskState(task);
const phase = typeof state.phase === 'string' ? state.phase : '';
const progressPercent =
typeof state.progressPercent === 'number'
? state.progressPercent
: typeof state.progressPercent === 'string'
? Number(state.progressPercent)
: null;
const name =
(typeof state.outputFilename === 'string' && state.outputFilename) ||
(typeof state.outputDirectoryName === 'string' && state.outputDirectoryName) ||
(typeof state.sourceFilename === 'string' && state.sourceFilename) ||
(typeof state.path === 'string' && state.path) ||
'-';
return {
...task,
phase,
progressPercent: Number.isFinite(progressPercent) ? Math.max(0, Math.min(100, Number(progressPercent))) : null,
name,
};
}),
[tasks],
);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="flex items-center justify-between mb-10">
<div>
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70">线 / </p>
</div>
<div className="flex items-center gap-8">
<label className="flex items-center gap-3 text-sm font-black uppercase tracking-widest cursor-pointer group">
<input
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
type="checkbox"
className="w-4 h-4 rounded-sm border-white/20 bg-white/10 checked:bg-blue-600 focus:ring-0 transition-all"
/>
<span className={cn("transition-opacity", autoRefresh ? "opacity-100" : "opacity-40")}></span>
</label>
<button
type="button"
onClick={() => {
setLoading(true);
void loadTasks();
}}
className="flex items-center gap-2 px-5 py-3 rounded-lg glass-panel hover:bg-white/40 transition-all font-black text-sm uppercase tracking-widest"
>
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
</button>
</div>
</div>
{error ? <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
<div className="flex-1 min-h-0">
{loading && rows.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70">...</div>
) : rows.length === 0 ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-sm font-black uppercase tracking-widest opacity-70"></div>
) : (
<div className="glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-left text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
<th className="px-8 py-5 text-right text-xs font-black uppercase tracking-[0.2em] opacity-70"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{rows.map((task) => (
<motion.tr key={task.id} variants={itemVariants} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
<td className="px-8 py-5 text-sm font-black tracking-widest uppercase opacity-90">{task.type}</td>
<td className="px-8 py-5 text-[12px] font-black tracking-tight uppercase truncate max-w-[150px]">{task.name}</td>
<td className="px-8 py-5">
<div className="flex items-center gap-2">
<span className={cn(
"px-2 py-0.5 rounded-sm text-xs font-black uppercase tracking-widest border",
task.status === 'RUNNING' ? "bg-blue-500/10 text-blue-500 border-blue-500/20 shadow-[0_0_10px_rgba(59,130,246,0.1)]" :
task.status === 'COMPLETED' ? "bg-green-500/10 text-green-500 border-green-500/20" :
task.status === 'FAILED' ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-gray-500/10 text-gray-500 border-white/10"
)}>
{task.status}
</span>
</div>
<div className="text-xs opacity-80 dark:opacity-90 font-black uppercase tracking-widest mt-1 ml-0.5">{task.phase || '等待中'}</div>
{task.errorMessage ? <div className="mt-1 text-xs text-red-500 font-bold uppercase tracking-tight">{task.errorMessage}</div> : null}
</td>
<td className="px-8 py-5">
<div className="mb-2 h-1 w-32 rounded-full bg-white/10 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${task.progressPercent ?? 0}%` }}
className={cn(
"h-full transition-all duration-500",
task.status === 'FAILED' ? 'bg-red-500' : 'bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]'
)}
/>
</div>
<div className="text-sm font-black opacity-80 dark:opacity-90">{task.progressPercent != null ? `${Math.round(task.progressPercent)}%` : '-'}</div>
</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tighter uppercase">{formatDateTime(task.updatedAt)}</td>
<td className="px-8 py-5 text-right">
<div className="flex justify-end gap-2 opactiy-40 group-hover:opacity-100 transition-opacity">
{task.status === 'FAILED' ? (
<button
type="button"
onClick={async () => {
await retryTask(task.id);
await loadTasks();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10 shadow-sm"
title="重试任务"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
{(task.status === 'QUEUED' || task.status === 'RUNNING') ? (
<button
type="button"
onClick={async () => {
await cancelTask(task.id);
await loadTasks();
}}
className="p-2.5 rounded-lg glass-panel hover:bg-red-500 hover:text-white text-red-500 transition-all border-white/10 shadow-sm"
title="取消任务"
>
<Square className="h-4 w-4" />
</button>
) : null}
</div>
</td>
</motion.tr>
))}
</motion.tbody>
</table>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,631 @@
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/src/lib/utils';
import { Clock3, Copy, Download, ExternalLink, FolderDown, Link as LinkIcon, RefreshCw, Send, Upload, ChevronRight } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { getSession } from '@/src/lib/session';
import {
buildOfflineTransferDownloadUrl,
createTransferSession,
importOfflineTransferFile,
joinTransferSession,
listMyOfflineTransferSessions,
lookupTransferSession,
sanitizePickupCode,
uploadOfflineTransferFile,
type LookupTransferSessionResponse,
type TransferFileItem,
type TransferMode,
type TransferSessionResponse,
} from '@/src/lib/transfer';
type TransferTab = 'send' | 'receive' | 'history';
function getTransferShareUrl(pickupCode: string) {
const url = new URL('/transfer', window.location.origin);
url.searchParams.set('code', pickupCode);
return url.toString();
}
function findSessionFile(sourceFile: File, sessionFiles: TransferFileItem[]) {
return sessionFiles.find(
(item) =>
item.name === sourceFile.name &&
item.relativePath.replaceAll('\\', '/') === (('webkitRelativePath' in sourceFile && sourceFile.webkitRelativePath) || sourceFile.name).replaceAll('\\', '/') &&
item.size === sourceFile.size,
);
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function Transfer() {
const [searchParams, setSearchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState<TransferTab>('send');
const [sendMode, setSendMode] = useState<TransferMode>('OFFLINE');
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [sendLoading, setSendLoading] = useState(false);
const [sendError, setSendError] = useState('');
const [sendMessage, setSendMessage] = useState('');
const [createdSession, setCreatedSession] = useState<TransferSessionResponse | null>(null);
const [uploadedCount, setUploadedCount] = useState(0);
const [receiveCode, setReceiveCode] = useState(() => sanitizePickupCode(searchParams.get('code') ?? ''));
const [lookupLoading, setLookupLoading] = useState(false);
const [receiveError, setReceiveError] = useState('');
const [receiveMessage, setReceiveMessage] = useState('');
const [lookupResult, setLookupResult] = useState<LookupTransferSessionResponse | null>(null);
const [joinedSession, setJoinedSession] = useState<TransferSessionResponse | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState('');
const [historyMessage, setHistoryMessage] = useState('');
const [historySessions, setHistorySessions] = useState<TransferSessionResponse[]>([]);
const loggedIn = Boolean(getSession());
useEffect(() => {
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
if (codeFromQuery && codeFromQuery !== receiveCode) {
setReceiveCode(codeFromQuery);
}
}, [searchParams]);
useEffect(() => {
if (activeTab !== 'history' || !loggedIn) {
return;
}
void loadHistory();
}, [activeTab, loggedIn]);
useEffect(() => {
const codeFromQuery = sanitizePickupCode(searchParams.get('code') ?? '');
if (!codeFromQuery) {
return;
}
setActiveTab('receive');
void handleLookup(codeFromQuery);
}, []);
const transferShareUrl = useMemo(
() => (createdSession ? getTransferShareUrl(createdSession.pickupCode) : ''),
[createdSession],
);
async function loadHistory() {
setHistoryLoading(true);
setHistoryError('');
try {
setHistorySessions(await listMyOfflineTransferSessions());
} catch (err) {
setHistoryError(err instanceof Error ? err.message : '加载记录失败');
} finally {
setHistoryLoading(false);
}
}
async function handleCreateSession() {
if (selectedFiles.length === 0) {
setSendError('请先选择至少一个文件。');
return;
}
setSendLoading(true);
setSendError('');
setSendMessage('');
setCreatedSession(null);
setUploadedCount(0);
try {
const session = await createTransferSession(selectedFiles, sendMode);
setCreatedSession(session);
if (session.mode === 'ONLINE') {
setSendMessage('在线快传会话已创建。目前暂未接入浏览器直连发送。');
return;
}
let completed = 0;
for (const file of selectedFiles) {
const matched = findSessionFile(file, session.files);
if (!matched?.id) {
throw new Error(`无法验证文件:${file.name}`);
}
await uploadOfflineTransferFile(session.sessionId, matched.id, file);
completed += 1;
setUploadedCount(completed);
}
setSendMessage('同步完成。');
} catch (err) {
setSendError(err instanceof Error ? err.message : '创建失败');
} finally {
setSendLoading(false);
}
}
async function handleLookup(code = receiveCode) {
const normalized = sanitizePickupCode(code);
if (normalized.length !== 6) {
setReceiveError('请输入 6 位取件码。');
return;
}
setLookupLoading(true);
setReceiveError('');
setReceiveMessage('');
setLookupResult(null);
setJoinedSession(null);
try {
const result = await lookupTransferSession(normalized);
setReceiveCode(result.pickupCode);
setLookupResult(result);
setSearchParams({ code: result.pickupCode });
} catch (err) {
setReceiveError(err instanceof Error ? err.message : '查找失败');
} finally {
setLookupLoading(false);
}
}
async function handleJoinSession() {
if (!lookupResult) {
return;
}
setLookupLoading(true);
setReceiveError('');
setReceiveMessage('');
try {
const session = await joinTransferSession(lookupResult.sessionId);
setJoinedSession(session);
if (session.mode === 'ONLINE') {
setReceiveMessage('在线会话已打开,等待发送方响应。');
} else {
setReceiveMessage('对象已就绪,可执行下载或导入。');
}
} catch (err) {
setReceiveError(err instanceof Error ? err.message : '打开失败');
} finally {
setLookupLoading(false);
}
}
async function handleImport(sessionId: string, fileId: string) {
const targetPath = window.prompt('导入到路径', '/') || '/';
try {
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
setReceiveMessage(`${saved.filename} -> ${saved.path}`);
} catch (err) {
setReceiveError(err instanceof Error ? err.message : '导入失败');
}
}
async function handleHistoryImport(sessionId: string, fileId: string) {
const targetPath = window.prompt('导入到路径', '/') || '/';
try {
const saved = await importOfflineTransferFile(sessionId, fileId, targetPath);
setHistoryMessage(`${saved.filename} -> ${saved.path}`);
} catch (err) {
setHistoryError(err instanceof Error ? err.message : '导入失败');
}
}
const uploadProgressText =
createdSession?.mode === 'OFFLINE' && selectedFiles.length > 0
? `${uploadedCount} / ${selectedFiles.length}`
: '-';
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col p-8 text-gray-900 dark:text-gray-100 overflow-y-auto"
>
<div className="mb-10">
<h1 className="text-4xl font-black tracking-tight animate-text-reveal"></h1>
<p className="mt-3 text-sm font-black uppercase tracking-[0.2em] opacity-70"> / </p>
</div>
<div className="mb-10 flex gap-2 p-1.5 rounded-lg glass-panel-no-hover w-fit shadow-2xl border border-white/10">
{([
['send', '发送'],
['receive', '接收'],
['history', '记录'],
] as const).map(([tab, label]) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
"rounded-md px-8 py-3 text-[10px] font-black uppercase tracking-widest transition-all duration-300",
activeTab === tab
? "bg-blue-600 text-white shadow-xl scale-[1.02]"
: "opacity-40 hover:opacity-100 hover:bg-white/10"
)}
>
{label}
</button>
))}
</div>
<div className="flex-1 min-h-0">
<AnimatePresence mode="wait">
{activeTab === 'send' && (
<motion.div
key="send"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]"
>
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
<div className="mb-8">
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70"></h2>
</div>
<div className="mb-10 flex gap-4">
{([
['OFFLINE', '离线快传'],
['ONLINE', '在线快传'],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setSendMode(mode)}
className={cn(
"rounded-lg px-6 py-2.5 text-[10px] font-black uppercase tracking-widest transition-all border",
sendMode === mode
? "bg-blue-600/10 border-blue-500/40 text-blue-500 shadow-inner"
: "border-white/10 opacity-30 hover:opacity-100 hover:bg-white/5"
)}
>
{label}
</button>
))}
</div>
<label className="mb-10 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/10 bg-white/5 px-10 py-20 text-center transition-all hover:border-blue-500/40 hover:bg-blue-500/5 group border-white/10">
<Upload className="mb-6 h-12 w-12 text-blue-500 opacity-40 group-hover:opacity-100 group-hover:scale-110 transition-all" />
<div className="text-[11px] font-black uppercase tracking-[0.2em]"></div>
<div className="mt-3 text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest"></div>
<input
type="file"
multiple
className="hidden"
onChange={(event) => {
setSelectedFiles(Array.from(event.target.files ?? []));
}}
/>
</label>
{selectedFiles.length > 0 && (
<div className="mb-10 rounded-lg bg-black/20 p-6 border border-white/10">
<div className="mb-4 text-xs font-black uppercase tracking-[0.3em] opacity-70">{selectedFiles.length}</div>
<div className="space-y-3 max-h-64 overflow-y-auto pr-2 custom-scrollbar">
{selectedFiles.map((file) => (
<div key={`${file.name}-${file.size}`} className="flex items-center justify-between gap-4 p-3 rounded bg-white/5 border border-white/5">
<span className="truncate text-[11px] font-black uppercase tracking-tight">{file.name}</span>
<span className="shrink-0 text-sm font-bold opacity-80 dark:opacity-90">{formatBytes(file.size)}</span>
</div>
))}
</div>
</div>
)}
{sendError && <div className="mb-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{sendError}</div>}
{sendMessage && <div className="mb-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{sendMessage}</div>}
<button
type="button"
onClick={() => void handleCreateSession()}
disabled={sendLoading || selectedFiles.length === 0}
className="w-full inline-flex items-center justify-center gap-4 rounded-lg bg-blue-600 px-8 py-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 hover:scale-[1.01] transition-all disabled:opacity-30 disabled:hover:scale-100"
>
{sendLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{sendLoading ? '处理中...' : '创建会话'}
</button>
</section>
<aside className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
<div className="mb-8">
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70"></h2>
</div>
{createdSession ? (
<div className="space-y-10">
<div className="rounded-lg bg-blue-600/5 dark:bg-blue-600/10 border border-blue-500/20 p-10 text-center">
<div className="text-[9px] font-black text-blue-500 uppercase tracking-[0.4em] mb-4"></div>
<div className="text-5xl font-black tracking-[0.3em] text-blue-500 ml-[0.3em] drop-shadow-xl">{createdSession.pickupCode}</div>
</div>
<div className="space-y-5 p-6 rounded-lg bg-white/5 border border-white/10 text-[10px] font-black uppercase tracking-widest">
<div className="flex justify-between border-b border-white/5 pb-3">
<span className="opacity-80 dark:opacity-90"></span>
<span>{createdSession.mode}</span>
</div>
<div className="flex justify-between border-b border-white/5 pb-3">
<span className="opacity-80 dark:opacity-90"></span>
<span className="text-amber-500">{formatDateTime(createdSession.expiresAt).split(' ')[0]}</span>
</div>
<div className="flex justify-between">
<span className="opacity-80 dark:opacity-90"></span>
<span className="text-blue-500">{uploadProgressText}</span>
</div>
</div>
<div className="grid grid-cols-1 gap-3">
<button
type="button"
onClick={() => { navigator.clipboard.writeText(createdSession.pickupCode); window.alert('取件码已复制'); }}
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(transferShareUrl); window.alert('链接已复制'); }}
className="flex items-center justify-center gap-3 rounded-lg glass-panel border-white/10 p-4 text-[9px] font-black uppercase tracking-[0.2em] hover:bg-white/40 transition-all"
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
</div>
) : (
<div className="py-20 text-center">
<div className="mb-6 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
<Copy className="h-10 w-10" />
</div>
<p className="text-sm font-black uppercase tracking-widest opacity-70"><br/></p>
</div>
)}
</aside>
</motion.div>
)}
{activeTab === 'receive' && (
<motion.div
key="receive"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
className="grid gap-8 lg:grid-cols-[0.8fr_1.2fr]"
>
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10 h-fit">
<div className="mb-8">
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70"></h2>
</div>
<div className="relative mb-8">
<input
value={receiveCode}
onChange={(event) => setReceiveCode(sanitizePickupCode(event.target.value))}
onKeyDown={(event) => { if (event.key === 'Enter') void handleLookup(); }}
placeholder="000000"
className="w-full rounded-lg glass-panel bg-black/40 p-8 text-center text-5xl font-black tracking-[0.5em] outline-none border border-white/10 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10 placeholder:opacity-10 transition-all duration-500 text-blue-500"
/>
</div>
<button
type="button"
onClick={() => void handleLookup()}
disabled={lookupLoading}
className="w-full rounded-lg bg-blue-600 p-5 text-[11px] font-black uppercase tracking-[0.3em] text-white shadow-2xl hover:bg-blue-500 transition-all disabled:opacity-30"
>
{lookupLoading ? <RefreshCw className="h-4 w-4 animate-spin inline mr-3" /> : null}
</button>
{receiveError && <div className="mt-8 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-[10px] text-red-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveError}</div>}
{receiveMessage && <div className="mt-8 rounded-lg bg-green-500/10 border border-green-500/20 px-6 py-4 text-[10px] text-green-600 font-black uppercase tracking-widest backdrop-blur-md">{receiveMessage}</div>}
{lookupResult && (
<div className="mt-10 p-8 rounded-lg bg-blue-600/5 border border-blue-500/20">
<div className="flex items-center gap-5 mb-6">
<div className="p-4 rounded-lg bg-blue-600 text-white font-black text-2xl tracking-[0.2em]">
{lookupResult.pickupCode}
</div>
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-blue-500"></div>
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{lookupResult.mode} MODE</div>
</div>
</div>
<button
type="button"
onClick={() => void handleJoinSession()}
disabled={lookupLoading}
className="w-full rounded-lg glass-panel border-white/10 p-4 text-[10px] font-black uppercase tracking-widest hover:bg-blue-600 hover:text-white transition-all"
>
</button>
</div>
)}
</section>
<section className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10">
<div className="mb-8">
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70"></h2>
</div>
{joinedSession ? (
joinedSession.mode === 'OFFLINE' ? (
<div className="space-y-4">
{joinedSession.files.map((file) => (
<div key={`${file.id}-${file.size}`} className="p-6 rounded-lg bg-white/5 border border-white/10 hover:bg-white/10 transition-all group">
<div className="flex flex-wrap items-center justify-between gap-6">
<div className="min-w-0 flex-1">
<div className="truncate text-lg font-black uppercase tracking-tight group-hover:text-blue-500 transition-colors">{file.name}</div>
<div className="mt-1 truncate text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-widest">{file.relativePath}</div>
<div className="mt-2 text-[10px] font-black text-blue-500 flex items-center gap-1">
<span className="opacity-40 font-black"></span>{formatBytes(file.size)}
</div>
</div>
<div className="flex gap-2">
{file.id && (
<a
href={buildOfflineTransferDownloadUrl(joinedSession.sessionId, file.id)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-3 text-[10px] font-black uppercase tracking-widest text-white hover:bg-blue-500 shadow-xl transition-all"
>
<Download className="h-4 w-4" />
</a>
)}
{loggedIn && file.id && (
<button
type="button"
onClick={() => void handleImport(joinedSession.sessionId, file.id!)}
className="inline-flex items-center gap-2 rounded-lg glass-panel border-white/10 px-5 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all"
>
<FolderDown className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="py-32 text-center rounded-lg bg-amber-500/5 border border-amber-500/10 px-10">
<RefreshCw className="h-12 w-12 text-amber-500 mx-auto mb-6 opacity-30 animate-spin-slow" />
<h3 className="text-[11px] font-black uppercase tracking-[0.3em] text-amber-500">线</h3>
<p className="mt-4 text-[10px] font-bold opacity-40 uppercase tracking-widest leading-relaxed">线线</p>
</div>
)
) : (
<div className="py-40 text-center">
<div className="mb-8 inline-flex p-6 rounded-lg bg-white/5 opacity-10">
<FolderDown className="h-10 w-10" />
</div>
<p className="text-sm font-black uppercase tracking-widest opacity-70"><br/></p>
</div>
)}
</section>
</motion.div>
)}
{activeTab === 'history' && (
<motion.div
key="history"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
className="glass-panel-no-hover rounded-lg p-10 shadow-3xl border border-white/10"
>
<div className="mb-10 flex items-center justify-between">
<div>
<h2 className="text-sm font-black uppercase tracking-[0.3em] opacity-70">线</h2>
</div>
<button
type="button"
onClick={() => void loadHistory()}
disabled={!loggedIn || historyLoading}
className="flex items-center gap-3 rounded-lg glass-panel border-white/10 px-6 py-3 text-[10px] font-black uppercase tracking-widest hover:bg-white/40 transition-all border-white/10 disabled:opacity-20"
>
<RefreshCw className={cn("h-4 w-4", historyLoading && "animate-spin")} />
</button>
</div>
{!loggedIn ? (
<div className="py-32 text-center">
<p className="text-sm font-black uppercase tracking-[0.3em] opacity-70"></p>
</div>
) : historyLoading && historySessions.length === 0 ? (
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70">...</div>
) : historySessions.length === 0 ? (
<div className="py-32 text-center text-sm font-black uppercase tracking-widest opacity-70"></div>
) : (
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-8"
>
{historyError && <div className="rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 font-bold">{historyError}</div>}
{historyMessage && <div className="rounded-lg bg-green-500/10 border border-green-500/30 px-6 py-4 text-xs text-green-600 font-bold">{historyMessage}</div>}
{historySessions.map((session) => (
<motion.div key={session.sessionId} variants={itemVariants} className="p-8 rounded-lg bg-white/5 border border-white/10 hover:border-blue-500/30 transition-all group">
<div className="mb-8 flex flex-wrap items-center justify-between gap-6">
<div className="flex items-center gap-6">
<div className="p-5 rounded-lg bg-blue-600 text-white font-black text-3xl tracking-[0.3em] shadow-xl">
{session.pickupCode}
</div>
<div>
<div className="flex items-center gap-2 text-[10px] font-black text-amber-500 mb-2 uppercase tracking-widest">
<Clock3 className="h-4 w-4" />
{formatDateTime(session.expiresAt).split(' ')[0]}
</div>
<div className="text-xs font-bold opacity-80 dark:opacity-90 uppercase tracking-[0.2em]">{session.files.length}</div>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => { navigator.clipboard.writeText(session.pickupCode); window.alert('取件码已复制'); }}
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
title="复制取件码"
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(getTransferShareUrl(session.pickupCode)); window.alert('链接已复制'); }}
className="p-3.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white transition-all border border-white/10 shadow-sm"
title="复制链接"
>
<LinkIcon className="h-4 w-4" />
</button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{session.files.map((file) => (
<div key={`${file.id}-${file.size}`} className="p-5 rounded-lg bg-black/40 border border-white/5 group/file">
<div className="truncate text-[11px] font-black uppercase tracking-tight mb-4 group-hover/file:text-blue-500 transition-colors">{file.name}</div>
<div className="flex items-center justify-between gap-3 border-t border-white/5 pt-3">
<span className="text-xs font-bold opacity-80 dark:opacity-90 uppercase">{formatBytes(file.size)}</span>
<div className="flex gap-2">
{file.id && (
<a
href={buildOfflineTransferDownloadUrl(session.sessionId, file.id)}
className="p-2 rounded-lg hover:bg-blue-600/20 text-blue-500 transition-colors"
title="下载"
>
<Download className="h-4 w-4" />
</a>
)}
{file.id && (
<button
type="button"
onClick={() => void handleHistoryImport(session.sessionId, file.id!)}
className="p-2 rounded-lg hover:bg-white/10 text-gray-700 dark:text-gray-100 opacity-80 hover:opacity-100 transition-all"
title="导入网盘"
>
<FolderDown className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
))}
</div>
</motion.div>
))}
</motion.div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,402 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronRight, Copy, Download, FolderPlus, HardDrive, Move, RefreshCw, Search, Share2, Trash2, Upload } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'motion/react';
import { createMediaMetadataTask } from '@/src/lib/background-tasks';
import { copyFile, createDirectory, deleteFile, getDownloadUrl, listFiles, moveFile, renameFile, searchFiles, type FileItem } from '@/src/lib/files';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { buildSharePublicUrl, createShare } from '@/src/lib/shares-v2';
import { uploadFileWithSession } from '@/src/lib/upload-session';
import { cn } from '@/src/lib/utils';
function joinPath(basePath: string, name: string) {
if (basePath === '/') {
return `/${name}`;
}
return `${basePath}/${name}`;
}
function splitPath(path: string) {
return path.split('/').filter(Boolean);
}
function isPathExpanded(currentPath: string, candidatePath: string) {
return currentPath === candidatePath || currentPath.startsWith(`${candidatePath}/`);
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.03
}
}
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 }
};
export default function FilesPage() {
const navigate = useNavigate();
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [path, setPath] = useState('/');
const [query, setQuery] = useState('');
const [files, setFiles] = useState<FileItem[]>([]);
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
const [directoryTree, setDirectoryTree] = useState<Record<string, FileItem[]>>({});
async function loadFiles(nextPath = path, nextQuery = query) {
setError('');
try {
const result = nextQuery.trim()
? await searchFiles(nextQuery.trim(), 0, 100)
: await listFiles(nextPath, 0, 100);
setFiles(result.items);
if (!nextQuery.trim()) {
setDirectoryTree((current) => ({
...current,
[nextPath]: result.items.filter((item) => item.directory),
}));
}
setSelectedFile((current) => result.items.find((item) => item.id === current?.id) ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : '加载文件失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFiles();
}, [path]);
const breadcrumbs = useMemo(() => splitPath(path), [path]);
function renderTreeNodes(basePath: string) {
const directories = directoryTree[basePath] ?? [];
return directories.map((item) => {
const nodePath = joinPath(basePath, item.filename);
const expanded = isPathExpanded(path, nodePath);
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() => setPath(nodePath)}
className={cn(
"group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-black uppercase tracking-wider transition-all",
path === nodePath
? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/20"
: "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5"
)}
>
<ChevronRight className={cn("h-3.5 w-3.5 opacity-40 transition-transform", expanded && "rotate-90")} />
<span className="truncate">{item.filename}</span>
</button>
{expanded && directoryTree[nodePath]?.length ? (
<div className="ml-4 border-l border-white/10 pl-2">
{renderTreeNodes(nodePath)}
</div>
) : null}
</div>
);
});
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex gap-6 h-full w-full p-8 overflow-hidden text-gray-900 dark:text-gray-100"
>
<aside className="hidden lg:flex w-72 flex-col flex-shrink-0 glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10">
<div className="border-b border-white/10 px-6 py-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-40"></h2>
</div>
<div className="flex-1 space-y-1.5 overflow-y-auto p-4 custom-scrollbar">
<button
type="button"
onClick={() => setPath('/')}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-black uppercase tracking-wider transition-all",
path === '/' ? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/20" : "text-gray-700 dark:text-gray-200 hover:bg-white/30 dark:hover:bg-white/5"
)}
>
<HardDrive className="h-4 w-4" />
</button>
{renderTreeNodes('/')}
</div>
<div className="border-t border-white/10 p-4">
<button
type="button"
onClick={() => navigate('/recycle-bin')}
className="flex w-full items-center gap-2 rounded-lg px-3 py-3 text-sm font-black uppercase tracking-widest text-gray-700 dark:text-gray-200 hover:text-red-500 hover:bg-red-500/5 transition-all"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col glass-panel-no-hover rounded-lg shadow-2xl overflow-hidden border-white/10">
<div className="border-b border-white/10 bg-white/5 dark:bg-black/20">
<div className="flex flex-col gap-6 px-8 py-6">
<div className="flex flex-wrap items-center justify-between gap-6">
<div className="flex flex-wrap items-center text-[11px] font-black uppercase tracking-widest">
<button type="button" onClick={() => setPath('/')} className="opacity-40 hover:opacity-100 transition-opacity">
</button>
{breadcrumbs.map((segment, index) => {
const target = `/${breadcrumbs.slice(0, index + 1).join('/')}`;
return (
<div key={target} className="flex items-center">
<ChevronRight className="mx-2 h-3 w-3 opacity-20" />
<button type="button" onClick={() => setPath(target)} className="opacity-40 hover:opacity-100 transition-opacity">
{segment}
</button>
</div>
);
})}
</div>
<div className="relative w-full max-w-sm group">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 opacity-70 group-focus-within:opacity-100 text-blue-500 transition-opacity" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLoading(true);
void loadFiles(path, event.currentTarget.value);
}
}}
placeholder="搜索文件..."
className="w-full rounded-lg glass-panel bg-white/20 dark:bg-black/40 py-3 pl-11 pr-5 outline-none focus:ring-4 focus:ring-blue-500/10 border-white/10 focus:border-blue-500/50 transition-all text-sm font-black uppercase tracking-widest placeholder:opacity-50"
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
ref={uploadInputRef}
type="file"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
setLoading(true);
try {
await uploadFileWithSession(file, path);
await loadFiles();
} catch (err) {
setError(err instanceof Error ? err.message : '上传失败');
setLoading(false);
} finally {
event.target.value = '';
}
}}
/>
<button
type="button"
onClick={() => uploadInputRef.current?.click()}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-black uppercase tracking-widest text-white shadow-xl hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] transition-all"
>
<Upload className="h-4 w-4" />
</button>
<button
type="button"
onClick={async () => {
const name = window.prompt('请输入文件夹名称');
if (!name) return;
setLoading(true);
try {
await createDirectory(joinPath(path, name));
await loadFiles();
} catch (err) {
setError(err instanceof Error ? err.message : '创建失败');
setLoading(false);
}
}}
className="flex items-center gap-2 rounded-lg glass-panel border-white/10 px-6 py-2.5 text-sm font-black uppercase tracking-widest text-gray-700 dark:text-gray-200 hover:bg-white/40 transition-all"
>
<FolderPlus className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setLoading(true);
void loadFiles();
}}
className="flex items-center gap-2 rounded-lg glass-panel border-white/10 px-6 py-2.5 text-sm font-black uppercase tracking-widest text-gray-700 dark:text-gray-200 hover:bg-white/40 transition-all border-white/10"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 relative z-10">
<div className="min-w-0 flex-1 overflow-y-auto p-8 custom-scrollbar">
{error ? <div className="mb-6 rounded-lg bg-red-500/10 border border-red-500/20 px-6 py-4 text-xs text-red-600 dark:text-red-400 font-bold backdrop-blur-md">{error}</div> : null}
{loading ? (
<div className="rounded-lg glass-panel border-white/10 px-4 py-24 text-center text-[10px] font-black uppercase tracking-[0.3em] opacity-40">...</div>
) : (
<div className="overflow-hidden rounded-lg glass-panel border-white/10 shadow-2xl relative shadow-blue-500/5">
<table className="min-w-full divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-8 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
</tr>
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{files.map((file) => (
<motion.tr
key={file.id}
variants={itemVariants}
onClick={() => setSelectedFile(file)}
onDoubleClick={() => {
if (file.directory) {
setPath(joinPath(file.path, file.filename));
}
}}
className={cn(
"cursor-pointer transition-all hover:bg-white/10 dark:hover:bg-white/5 group",
selectedFile?.id === file.id ? "bg-white/15 dark:bg-black/40 shadow-inner" : ""
)}
>
<td className="px-8 py-5 text-[13px] font-black tracking-tight group-hover:text-blue-500 transition-colors uppercase">{file.filename}</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tight uppercase">{file.path}</td>
<td className="px-8 py-5 text-[10px] font-black opacity-50 tracking-tighter">{file.directory ? '目录' : formatBytes(file.size)}</td>
<td className="px-8 py-5 text-sm font-bold opacity-80 dark:opacity-90 tracking-tighter uppercase">{formatDateTime(file.createdAt)}</td>
</motion.tr>
))}
{files.length === 0 ? (
<tr>
<td colSpan={4} className="px-8 py-24 text-center text-sm font-black uppercase tracking-widest opacity-70">
{query.trim() ? '没有匹配文件' : '当前目录为空'}
</td>
</tr>
) : null}
</motion.tbody>
</table>
</div>
)}
</div>
<AnimatePresence mode="wait">
{selectedFile && (
<motion.aside
initial={{ x: 300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 300, opacity: 0 }}
className="hidden xl:flex w-96 flex-shrink-0 flex-col glass-panel-no-hover rounded-lg overflow-hidden shadow-2xl border-white/10 m-8 ml-0"
>
<div className="flex-1 overflow-y-auto p-8 space-y-10 custom-scrollbar">
<div>
<div className="text-sm font-black uppercase tracking-[0.3em] opacity-70 mb-2"></div>
<h2 className="text-2xl font-black text-gray-900 group-hover:text-blue-500 uppercase tracking-tighter break-all">{selectedFile.filename}</h2>
<div className="mt-3 text-sm font-bold opacity-80 dark:opacity-90 bg-white/5 rounded px-2 py-1 inline-block uppercase tracking-tight">{selectedFile.path}</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<div className="text-xs font-black uppercase tracking-widest opacity-70 mb-1"></div>
<div className="text-xs font-black uppercase">{selectedFile.directory ? '目录' : selectedFile.contentType || '文件'}</div>
</div>
<div>
<div className="text-xs font-black uppercase tracking-widest opacity-70 mb-1"></div>
<div className="text-xs font-black">{selectedFile.directory ? '-' : formatBytes(selectedFile.size)}</div>
</div>
</div>
<div>
<div className="text-xs font-black uppercase tracking-widest opacity-70 mb-2"></div>
<div className="space-y-2">
<button
type="button"
onClick={async () => {
const result = await getDownloadUrl(selectedFile.id);
window.open(result.url, '_blank', 'noopener,noreferrer');
}}
className="flex w-full items-center gap-3 rounded-lg glass-panel border-white/10 px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:bg-blue-600 hover:text-white transition-all group"
>
<Download className="h-4 w-4 group-hover:scale-110 transition-transform" />
</button>
<button
type="button"
onClick={async () => {
const result = await createShare({ fileId: selectedFile.id });
await navigator.clipboard.writeText(buildSharePublicUrl(result.token));
window.alert('分享链接已复制');
}}
className="flex w-full items-center gap-3 rounded-lg glass-panel border-white/10 px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-gray-700 dark:text-gray-200 hover:bg-white/40 transition-all border-white/10"
>
<Share2 className="h-4 w-4" />
</button>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={async () => {
const nextName = window.prompt('请输入新名称', selectedFile.filename);
if (nextName) { await renameFile(selectedFile.id, nextName); await loadFiles(); }
}}
className="flex items-center justify-center gap-2 rounded-lg glass-panel border-white/10 p-4 text-xs font-black uppercase tracking-widest hover:bg-white/40 transition-all"
>
</button>
<button
type="button"
onClick={async () => {
const targetPath = window.prompt('请输入目标路径', selectedFile.path);
if (targetPath) { await moveFile(selectedFile.id, targetPath); await loadFiles(); }
}}
className="flex items-center justify-center gap-2 rounded-lg glass-panel border-white/10 p-4 text-xs font-black uppercase tracking-widest hover:bg-white/40 transition-all"
>
</button>
</div>
<button
type="button"
onClick={async () => {
if (!window.confirm(`确认删除 ${selectedFile.filename} 吗?`)) return;
await deleteFile(selectedFile.id);
await loadFiles();
}}
className="flex w-full items-center gap-3 rounded-lg glass-panel border-white/10 px-4 py-4 text-sm font-black uppercase tracking-[0.2em] text-red-500 hover:bg-red-500 hover:text-white transition-all border-red-500/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
</div>
</motion.div>
);
}

26
front_zip/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

30
front_zip/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
const backendUrl = env.VITE_BACKEND_URL || 'http://127.0.0.1:8080';
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
},
},
};
});