feat: implement ufile sharing service

This commit is contained in:
loveuer
2026-09-09 20:53:39 -07:00
commit d3016b94db
19 changed files with 4022 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7f2" />
<title>ufile · 文件共享中心</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2363
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "ufile-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.13",
"@vitejs/plugin-react": "^5.0.2",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.13",
"vite": "^7.1.4",
"zustand": "^5.0.8"
},
"devDependencies": {}
}
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState } from 'react'
import {
ArrowDownToLine, Check, Copy, Eye, FileArchive, FileUp,
KeyRound, LoaderCircle, LogOut, ShieldCheck,
UploadCloud, Users, X,
} from 'lucide-react'
import { api, json } from './api'
import { useAppStore } from './store'
const formatBytes = (bytes) => {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unit = 0
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++ }
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`
}
const formatDate = (value) => value ? new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).format(new Date(value)) : '永久'
function Toast({ message, onClose }) {
useEffect(() => { const timer = setTimeout(onClose, 3600); return () => clearTimeout(timer) }, [message, onClose])
return <div className="fixed bottom-5 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-xl bg-ink px-4 py-3 text-sm text-white shadow-xl"><Check size={16} />{message}</div>
}
function Login() {
const login = useAppStore((s) => s.login)
const [form, setForm] = useState({ username: '', password: '' })
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [pickup, setPickup] = useState({ hash: '', code: '' })
const retrieve = (preview = false) => {
const hash = pickup.hash.trim()
if (!hash) return
const query = new URLSearchParams()
if (pickup.code) query.set('code', pickup.code)
if (preview) query.set('preview', 'true')
window.open(`/uf/${encodeURIComponent(hash)}${query.size ? `?${query}` : ''}`, '_blank')
}
const submit = async (event) => {
event.preventDefault(); setBusy(true); setError('')
try { await login(form.username, form.password) }
catch (e) { setError(e.message) }
finally { setBusy(false) }
}
return <main className="noise min-h-screen px-5 py-8 lg:grid lg:grid-cols-[1.08fr_.92fr] lg:items-center lg:px-[8vw]">
<section className="mx-auto max-w-2xl py-8 lg:mx-0">
<div className="mb-12 flex items-center gap-3"><div className="grid size-11 place-items-center rounded-2xl bg-leaf text-white"><FileArchive size={23} /></div><div><p className="text-xl font-semibold tracking-tight">ufile</p><p className="text-xs text-ink/45">轻量文件共享中心</p></div></div>
<p className="mb-4 text-sm font-medium uppercase tracking-[.24em] text-leaf">Send less. Share better.</p>
<h1 className="max-w-xl text-5xl font-semibold leading-[1.08] tracking-[-.045em] sm:text-6xl">把文件交给链接,<br />把空间留给重要的事。</h1>
<p className="mt-6 max-w-lg text-base leading-7 text-ink/55">限时保存、下载次数与取件码,让每一次分享都清晰可控。</p>
<div className="card mt-10 max-w-lg p-5">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold"><ArrowDownToLine size={17} className="text-leaf" />收到分享?直接取件</div>
<div className="grid gap-2 sm:grid-cols-[1fr_130px_auto]">
<input className="field" maxLength={8} placeholder="8 位文件码" value={pickup.hash} onChange={(e) => setPickup({ ...pickup, hash: e.target.value })} />
<input className="field" inputMode="numeric" maxLength={6} placeholder="取件码(可选)" value={pickup.code} onChange={(e) => setPickup({ ...pickup, code: e.target.value.replace(/\D/g, '') })} />
<button className="btn-secondary" onClick={() => retrieve(false)}>下载</button>
</div>
</div>
</section>
<section className="mx-auto w-full max-w-md py-8 lg:justify-self-end">
<form className="card p-7 sm:p-9" onSubmit={submit}>
<div className="mb-8"><div className="mb-4 grid size-10 place-items-center rounded-xl bg-mint text-leaf"><KeyRound size={20} /></div><h2 className="text-2xl font-semibold tracking-tight">登录管理空间</h2><p className="mt-1.5 text-sm text-ink/50">上传文件或管理系统用户</p></div>
<label className="label">用户名</label><input autoFocus className="field mb-4" required value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
<label className="label">密码</label><input className="field" type="password" required value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<button className="btn-primary mt-6 w-full" disabled={busy}>{busy ? <LoaderCircle className="animate-spin" size={18} /> : '登录'}</button>
</form>
</section>
</main>
}
function ChangePassword() {
const user = useAppStore((s) => s.user)
const setUser = useAppStore((s) => s.setUser)
const logout = useAppStore((s) => s.logout)
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' })
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
if (form.newPassword !== form.confirm) return setError('两次输入的新密码不一致')
try { setUser(await api('/api/auth/password', json('PUT', form))) }
catch (err) { setError(err.message) }
}
return <main className="noise grid min-h-screen place-items-center px-5"><form onSubmit={submit} className="card w-full max-w-md p-8">
<div className="mb-5 grid size-12 place-items-center rounded-2xl bg-amber-100 text-amber-700"><KeyRound /></div>
<h1 className="text-2xl font-semibold">先设置你的新密码</h1><p className="mt-2 text-sm leading-6 text-ink/55">你好,{user.username}。临时密码只能用于首次登录,新密码至少 8 位。</p>
<label className="label mt-7">当前临时密码</label><input className="field" type="password" required value={form.currentPassword} onChange={(e) => setForm({ ...form, currentPassword: e.target.value })} />
<label className="label mt-4">新密码</label><input className="field" type="password" minLength={8} required value={form.newPassword} onChange={(e) => setForm({ ...form, newPassword: e.target.value })} />
<label className="label mt-4">再次输入新密码</label><input className="field" type="password" minLength={8} required value={form.confirm} onChange={(e) => setForm({ ...form, confirm: e.target.value })} />
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<button className="btn-primary mt-6 w-full">完成设置</button><button type="button" className="mt-3 w-full py-2 text-sm text-ink/45 hover:text-ink" onClick={logout}>退出登录</button>
</form></main>
}
function UploadPanel() {
const user = useAppStore((s) => s.user)
const [file, setFile] = useState(null)
const [retention, setRetention] = useState('1d')
const [downloads, setDownloads] = useState('5')
const [customDownloads, setCustomDownloads] = useState('20')
const [pickupCode, setPickupCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null)
const allowed = user.canUpload || user.canAdvancedUpload
const submit = async (event) => {
event.preventDefault(); if (!file) return
setBusy(true); setError('')
const data = new FormData(); data.set('file', file); data.set('retention', retention)
data.set('downloads', downloads === 'custom' ? customDownloads : downloads); data.set('pickupCode', pickupCode)
try { const uploaded = await api('/api/files', { method: 'POST', body: data }); setResult({ ...uploaded, pickupCode }); setFile(null) }
catch (e) { setError(e.message) }
finally { setBusy(false) }
}
if (!allowed) return <div className="card p-8 text-center"><ShieldCheck className="mx-auto mb-3 text-ink/25" size={32} /><h2 className="font-semibold">当前账号没有上传权限</h2><p className="mt-1 text-sm text-ink/50">请联系用户管理员调整权限。</p></div>
if (result) return <UploadResult file={result} onAgain={() => { setResult(null); setPickupCode('') }} />
return <form className="card p-5 sm:p-6" onSubmit={submit}>
<div className="mb-5 flex items-start justify-between"><div><h2 className="text-lg font-semibold">上传新文件</h2><p className="mt-1 text-sm text-ink/45">{user.canAdvancedUpload ? '高级上传 · 不限有效文件数量' : '普通上传 · 最多 10 个有效文件'}</p></div><UploadCloud className="text-leaf" /></div>
<label className="group grid min-h-36 cursor-pointer place-items-center rounded-2xl border border-dashed border-ink/20 bg-paper/60 p-5 text-center transition hover:border-leaf/50 hover:bg-mint/25">
<input className="hidden" type="file" onChange={(e) => setFile(e.target.files[0] || null)} />
<div><FileUp className="mx-auto mb-3 text-leaf" /><p className="max-w-sm truncate font-medium">{file ? file.name : '点击选择文件'}</p><p className="mt-1 text-xs text-ink/40">{file ? formatBytes(file.size) : '文件大小取决于服务器可用空间'}</p></div>
</label>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div><label className="label">保留时间</label><select className="field" value={retention} onChange={(e) => setRetention(e.target.value)}><option value="1h">1 小时</option><option value="8h">8 小时</option><option value="1d">1 天</option><option value="3d">3 天</option>{user.canAdvancedUpload && <option value="forever">永久</option>}</select></div>
<div><label className="label">可访问次数</label><select className="field" value={downloads} onChange={(e) => setDownloads(e.target.value)}><option value="1">1 次</option><option value="3">3 次</option><option value="5">5 次</option><option value="10">10 次</option><option value="custom">自定义</option>{user.canAdvancedUpload && <option value="unlimited">无限</option>}</select></div>
{downloads === 'custom' && <div><label className="label">自定义次数(1–99)</label><input className="field" type="number" min="1" max="99" required value={customDownloads} onChange={(e) => setCustomDownloads(e.target.value)} /></div>}
<div className={downloads === 'custom' ? '' : 'sm:col-span-2'}><label className="label">6 位数字取件码(可选)</label><input className="field" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="留空则无需取件码" value={pickupCode} onChange={(e) => setPickupCode(e.target.value.replace(/\D/g, ''))} /></div>
</div>
{error && <p className="mt-4 text-sm text-red-600">{error}</p>}
<button className="btn-primary mt-5 w-full" disabled={!file || busy}>{busy ? <><LoaderCircle className="animate-spin" size={18} />正在上传</> : <><UploadCloud size={18} />上传并生成链接</>}</button>
</form>
}
function UploadResult({ file, onAgain }) {
const [copied, setCopied] = useState('')
const query = new URLSearchParams()
if (file.pickupCode) query.set('code', file.pickupCode)
const download = `${location.origin}/uf/${file.hash}${query.size ? `?${query}` : ''}`
query.set('preview', 'true')
const preview = `${location.origin}/uf/${file.hash}?${query}`
const copy = async (value, type) => { await navigator.clipboard.writeText(value); setCopied(type); setTimeout(() => setCopied(''), 1800) }
const row = (title, value, type, icon) => <div><label className="label">{title}</label><div className="flex gap-2"><div className="field min-w-0 flex-1 truncate font-mono text-sm">{value}</div><button className="btn-secondary shrink-0 !px-3" onClick={() => copy(value, type)}>{copied === type ? <Check size={17} /> : icon}</button></div></div>
return <section className="card mx-auto w-full max-w-2xl p-6 sm:p-8">
<div className="mb-7 flex items-start gap-4"><div className="grid size-12 shrink-0 place-items-center rounded-2xl bg-mint text-leaf"><Check size={24} /></div><div className="min-w-0"><h2 className="text-xl font-semibold">文件已准备好</h2><p className="mt-1 truncate text-sm text-ink/50">{file.name} · {formatBytes(file.size)}</p></div></div>
<div className="space-y-4">{row('文件码', file.hash, 'hash', <Copy size={17} />)}{row('下载链接', download, 'download', <Copy size={17} />)}{row('预览链接', preview, 'preview', <Eye size={17} />)}{file.pickupCode && row('取件码', file.pickupCode, 'code', <Copy size={17} />)}</div>
<div className="mt-5 rounded-xl bg-paper p-4 text-sm text-ink/55">有效期:{formatDate(file.expiresAt)} · 访问次数:{file.maxDownloads == null ? '无限' : `${file.maxDownloads} 次`} {file.pickupCode && '· 链接已包含取件码,可按需分开发送'}</div>
<button className="btn-primary mt-6 w-full" onClick={onAgain}><UploadCloud size={18} />继续上传</button>
</section>
}
const emptyUser = { username: '', canManageUsers: false, canUpload: true, canAdvancedUpload: false }
function UserModal({ value, onClose, onSaved }) {
const [form, setForm] = useState(value || emptyUser)
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
try { onSaved(await api(value ? `/api/users/${value.id}` : '/api/users', json(value ? 'PUT' : 'POST', form))) }
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint) => <label className="flex cursor-pointer gap-3 rounded-xl border border-line p-3.5"><input className="mt-1 size-4 accent-leaf" type="checkbox" checked={form[key]} onChange={(e) => setForm({ ...form, [key]: e.target.checked })} /><span><span className="block text-sm font-medium">{title}</span><span className="mt-0.5 block text-xs text-ink/45">{hint}</span></span></label>
return <div className="fixed inset-0 z-40 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><form onSubmit={submit} className="card w-full max-w-md p-6"><div className="flex items-center justify-between"><h2 className="text-lg font-semibold">{value ? '编辑用户' : '创建用户'}</h2><button type="button" onClick={onClose}><X size={20} /></button></div><label className="label mt-5">用户名</label><input className="field" required pattern="[A-Za-z0-9_.-]{3,32}" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /><div className="mt-4 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件')}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载')}</div>{error && <p className="mt-3 text-sm text-red-600">{error}</p>}<div className="mt-6 flex justify-end gap-2"><button type="button" className="btn-secondary" onClick={onClose}>取消</button><button className="btn-primary">保存</button></div></form></div>
}
function UsersPanel({ notify }) {
const users = useAppStore((s) => s.users)
const loadUsers = useAppStore((s) => s.loadUsers)
const [editing, setEditing] = useState(undefined)
const [modalOpen, setModalOpen] = useState(false)
const [secret, setSecret] = useState(null)
useEffect(() => { loadUsers() }, [loadUsers])
const saved = async (result) => { setModalOpen(false); setEditing(undefined); await loadUsers(); if (result.temporaryPassword) setSecret({ title: '用户已创建', password: result.temporaryPassword }); else notify('用户信息已保存') }
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') }
return <section className="card overflow-hidden"><div className="flex items-center justify-between border-b border-line px-5 py-4"><div><h2 className="font-semibold">用户管理</h2><p className="mt-0.5 text-xs text-ink/40">权限修改立即生效</p></div><button className="btn-primary !py-2 text-sm" onClick={() => { setEditing(undefined); setModalOpen(true) }}>创建用户</button></div><div className="divide-y divide-line">{users.map((user) => <div key={user.id} className="flex flex-col gap-3 p-5 sm:flex-row sm:items-center"><div className="grid size-10 shrink-0 place-items-center rounded-full bg-paper font-semibold text-leaf">{user.username.slice(0, 1).toUpperCase()}</div><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><p className="font-medium">{user.username}</p><span className="text-xs text-ink/35">#{user.id}</span>{user.mustChangePassword && <span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700">待改密</span>}</div><p className="mt-1 text-xs text-ink/45">{[user.canManageUsers && '用户管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div><div className="flex gap-2"><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => { setEditing(user); setModalOpen(true) }}>编辑</button><button className="btn-secondary !px-3 !py-2 text-xs" onClick={() => reset(user)}>重置密码</button>{user.id !== 1 && <button className="rounded-xl px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50" onClick={() => remove(user)}>删除</button>}</div></div>)}</div>
{modalOpen && <UserModal value={editing} onClose={() => setModalOpen(false)} onSaved={saved} />}
{secret && <div className="fixed inset-0 z-50 grid place-items-center bg-ink/30 p-5 backdrop-blur-sm"><div className="card w-full max-w-md p-7"><h2 className="text-lg font-semibold">{secret.title}</h2><p className="mt-2 text-sm text-ink/50">该密码只显示这一次,用户登录后必须修改。</p><div className="mt-5 flex items-center justify-between rounded-xl bg-paper p-4 font-mono text-sm"><span className="break-all">{secret.password}</span><button onClick={() => navigator.clipboard.writeText(secret.password)}><Copy size={17} /></button></div><button className="btn-primary mt-5 w-full" onClick={() => setSecret(null)}>我已保存</button></div></div>}
</section>
}
function Dashboard() {
const user = useAppStore((s) => s.user)
const logout = useAppStore((s) => s.logout)
const [tab, setTab] = useState('files')
const [toast, setToast] = useState('')
const notify = (message) => setToast(message)
return <div className="noise min-h-screen"><header className="sticky top-0 z-20 border-b border-line/80 bg-paper/90 backdrop-blur-xl"><div className="mx-auto flex max-w-6xl items-center justify-between px-5 py-3.5"><div className="flex items-center gap-2.5"><div className="grid size-9 place-items-center rounded-xl bg-leaf text-white"><FileArchive size={19} /></div><span className="font-semibold">ufile</span></div><div className="flex items-center gap-3"><span className="hidden text-sm text-ink/50 sm:block">{user.username}</span><button className="btn-secondary !p-2.5" title="退出登录" onClick={logout}><LogOut size={16} /></button></div></div></header>
<main className="mx-auto max-w-6xl px-5 py-7"><div className="mb-7 flex gap-1 rounded-xl bg-ink/5 p-1 sm:w-fit"><button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('files')}><FileUp size={16} />文件空间</button>{user.canManageUsers && <button className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'users' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('users')}><Users size={16} />用户管理</button>}</div>
{tab === 'files' ? <div className="mx-auto max-w-2xl"><UploadPanel /></div> : <UsersPanel notify={notify} />}
</main>{toast && <Toast message={toast} onClose={() => setToast('')} />}
</div>
}
export default function App() {
const { user, checking, checkSession } = useAppStore()
useEffect(() => { checkSession() }, [checkSession])
if (checking) return <div className="grid min-h-screen place-items-center bg-paper"><LoaderCircle className="animate-spin text-leaf" /></div>
if (!user) return <Login />
if (user.mustChangePassword) return <ChangePassword />
return <Dashboard />
}
+18
View File
@@ -0,0 +1,18 @@
export async function api(path, options = {}) {
const response = await fetch(path, options)
if (response.status === 204) return null
const body = await response.json().catch(() => ({}))
if (!response.ok) {
const error = new Error(body.error || '请求失败')
error.code = body.code
error.status = response.status
throw error
}
return body
}
export const json = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
@theme {
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", system-ui, sans-serif;
--color-ink: #17211b;
--color-paper: #f6f7f2;
--color-leaf: #176b45;
--color-mint: #d8eee1;
--color-line: #dce2da;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #f6f7f2; color: #17211b; }
button, input, select { font: inherit; }
button { cursor: pointer; }
.noise {
background-image: radial-gradient(rgba(23, 33, 27, .08) .55px, transparent .55px);
background-size: 6px 6px;
}
.card { @apply rounded-2xl border border-line bg-white shadow-[0_12px_35px_rgba(23,33,27,0.06)]; }
.field { @apply w-full rounded-xl border border-line bg-white px-3.5 py-2.5 outline-none transition focus:border-leaf focus:ring-3 focus:ring-leaf/10; }
.btn-primary { @apply inline-flex items-center justify-center gap-2 rounded-xl bg-leaf px-4 py-2.5 font-medium text-white transition hover:bg-[#105638] disabled:cursor-not-allowed disabled:opacity-50; }
.btn-secondary { @apply inline-flex items-center justify-center gap-2 rounded-xl border border-line bg-white px-4 py-2.5 font-medium transition hover:border-leaf/40 hover:bg-mint/30 disabled:opacity-50; }
.label { @apply mb-1.5 block text-sm font-medium text-ink/70; }
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>,
)
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand'
import { api, json } from './api'
export const useAppStore = create((set) => ({
user: null,
checking: true,
users: [],
async checkSession() {
try { set({ user: await api('/api/auth/me') }) }
catch { set({ user: null }) }
finally { set({ checking: false }) }
},
async login(username, password) {
const user = await api('/api/auth/login', json('POST', { username, password }))
set({ user })
},
async logout() {
await api('/api/auth/logout', { method: 'POST' })
set({ user: null, users: [] })
},
setUser: (user) => set({ user }),
async loadUsers() { set({ users: await api('/api/users') }) },
}))
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:6616',
'/uf': 'http://localhost:6616',
},
},
})