Refactor backend and frontend modules for architecture alignment
This commit is contained in:
9
front_zip/.env.example
Normal file
9
front_zip/.env.example
Normal 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
8
front_zip/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
20
front_zip/README.md
Normal file
20
front_zip/README.md
Normal 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
16
front_zip/index.html
Normal 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
5
front_zip/metadata.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
4383
front_zip/package-lock.json
generated
Normal file
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
37
front_zip/package.json
Normal 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
58
front_zip/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
front_zip/src/admin/dashboard.tsx
Normal file
205
front_zip/src/admin/dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
front_zip/src/admin/files-list.tsx
Normal file
181
front_zip/src/admin/files-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
383
front_zip/src/admin/storage-policies-list.tsx
Normal file
383
front_zip/src/admin/storage-policies-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
front_zip/src/admin/users-list.tsx
Normal file
260
front_zip/src/admin/users-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
front_zip/src/components/ThemeProvider.tsx
Normal file
73
front_zip/src/components/ThemeProvider.tsx
Normal 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;
|
||||
};
|
||||
17
front_zip/src/components/ThemeToggle.tsx
Normal file
17
front_zip/src/components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
front_zip/src/components/layout/Layout.tsx
Normal file
120
front_zip/src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
front_zip/src/hooks/useIsMobile.ts
Normal file
20
front_zip/src/hooks/useIsMobile.ts
Normal 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
146
front_zip/src/index.css
Normal 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; }
|
||||
}
|
||||
77
front_zip/src/lib/admin-storage-policies.ts
Normal file
77
front_zip/src/lib/admin-storage-policies.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
69
front_zip/src/lib/admin-users.ts
Normal file
69
front_zip/src/lib/admin-users.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
56
front_zip/src/lib/admin.ts
Normal file
56
front_zip/src/lib/admin.ts
Normal 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
192
front_zip/src/lib/api.ts
Normal 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
58
front_zip/src/lib/auth.ts
Normal 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();
|
||||
}
|
||||
46
front_zip/src/lib/background-tasks.ts
Normal file
46
front_zip/src/lib/background-tasks.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
16
front_zip/src/lib/file-events.ts
Normal file
16
front_zip/src/lib/file-events.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
3
front_zip/src/lib/file-search.ts
Normal file
3
front_zip/src/lib/file-search.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { searchFiles } from './files';
|
||||
|
||||
export { searchFiles };
|
||||
119
front_zip/src/lib/files.ts
Normal file
119
front_zip/src/lib/files.ts
Normal 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()}`);
|
||||
}
|
||||
58
front_zip/src/lib/format.ts
Normal file
58
front_zip/src/lib/format.ts
Normal 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)))}%`;
|
||||
}
|
||||
47
front_zip/src/lib/session.ts
Normal file
47
front_zip/src/lib/session.ts
Normal 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 }));
|
||||
}
|
||||
84
front_zip/src/lib/shares-v2.ts
Normal file
84
front_zip/src/lib/shares-v2.ts
Normal 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();
|
||||
}
|
||||
39
front_zip/src/lib/transfer.test.ts
Normal file
39
front_zip/src/lib/transfer.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
113
front_zip/src/lib/transfer.ts
Normal file
113
front_zip/src/lib/transfer.ts
Normal 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 }),
|
||||
},
|
||||
);
|
||||
}
|
||||
131
front_zip/src/lib/upload-session.ts
Normal file
131
front_zip/src/lib/upload-session.ts
Normal 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);
|
||||
}
|
||||
6
front_zip/src/lib/utils.ts
Normal file
6
front_zip/src/lib/utils.ts
Normal 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
13
front_zip/src/main.tsx
Normal 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>,
|
||||
);
|
||||
102
front_zip/src/mobile-components/MobileLayout.tsx
Normal file
102
front_zip/src/mobile-components/MobileLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
228
front_zip/src/pages/FileShare.tsx
Normal file
228
front_zip/src/pages/FileShare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
front_zip/src/pages/Login.tsx
Normal file
249
front_zip/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
front_zip/src/pages/Overview.tsx
Normal file
202
front_zip/src/pages/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
front_zip/src/pages/RecycleBin.tsx
Normal file
115
front_zip/src/pages/RecycleBin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
front_zip/src/pages/Shares.tsx
Normal file
142
front_zip/src/pages/Shares.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
front_zip/src/pages/Tasks.tsx
Normal file
227
front_zip/src/pages/Tasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
631
front_zip/src/pages/Transfer.tsx
Normal file
631
front_zip/src/pages/Transfer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
402
front_zip/src/pages/files/FilesPage.tsx
Normal file
402
front_zip/src/pages/files/FilesPage.tsx
Normal 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
26
front_zip/tsconfig.json
Normal 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
30
front_zip/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user