feat: add file management and branding
Release / deploy (push) Successful in 2m26s

This commit is contained in:
loveuer
2026-09-10 20:40:57 -07:00
parent 8ca8341d7c
commit 3907410354
9 changed files with 209 additions and 36 deletions
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f7f2" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<title>ufile · 文件共享中心</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect x="16" y="16" width="224" height="224" rx="56" fill="#176b45"/>
<path d="M76 52h72l32 32v120H76z" fill="#fff"/>
<path d="M148 52v32h32z" fill="#bfe8d2"/>
<path d="M104 112v43a24 24 0 0 0 48 0v-43" fill="none" stroke="#176b45" stroke-linecap="round" stroke-width="16"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B

+30 -8
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import {
ArrowDownToLine, Check, Copy, Eye, FileArchive, FileUp,
ArrowDownToLine, Check, Copy, Eye, FileArchive, Files, FileUp,
KeyRound, LoaderCircle, LogOut, ShieldCheck,
UploadCloud, Users, X,
Trash2, UploadCloud, Users, X,
} from 'lucide-react'
import { api, json } from './api'
import { useAppStore } from './store'
@@ -164,7 +164,7 @@ function UploadResult({ file, onAgain }) {
function UserModal({ value, onClose, onSaved }) {
const actor = useAppStore((s) => s.user)
const canGrantUpload = actor.canUpload || actor.canAdvancedUpload
const [form, setForm] = useState(value || { username: '', canManageUsers: false, canUpload: canGrantUpload, canAdvancedUpload: false })
const [form, setForm] = useState(value || { username: '', canManageUsers: false, canManageFiles: false, canUpload: canGrantUpload, canAdvancedUpload: false })
const [error, setError] = useState('')
const submit = async (e) => {
e.preventDefault(); setError('')
@@ -172,7 +172,7 @@ function UserModal({ value, onClose, onSaved }) {
catch (err) { setError(err.message) }
}
const checkbox = (key, title, hint, disabled = false) => <label className={`flex gap-3 rounded-xl border border-line p-3.5 ${disabled ? 'cursor-not-allowed opacity-45' : 'cursor-pointer'}`}><input className="mt-1 size-4 accent-leaf" type="checkbox" disabled={disabled} 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 })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.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>
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 max-h-[90vh] w-full max-w-md overflow-y-auto 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 })} /><p className="mt-4 text-xs text-ink/45">只能分配你当前拥有的权限。</p><div className="mt-2 space-y-2">{checkbox('canManageUsers', '用户管理', '创建、编辑、重置或删除用户')}{checkbox('canManageFiles', '文件管理', '查看并删除所有用户上传的文件', !actor.canManageFiles)}{checkbox('canUpload', '普通上传', '最多保留 10 个有效文件', !canGrantUpload)}{checkbox('canAdvancedUpload', '高级上传', '涵盖普通上传,并允许永久、无限下载', !actor.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 }) {
@@ -184,15 +184,37 @@ function UsersPanel({ notify }) {
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 manageable = (user) => (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
const manageable = (user) => (!user.canManageFiles || actor.canManageFiles) && (!user.canAdvancedUpload || actor.canAdvancedUpload) && (!user.canUpload || actor.canUpload || actor.canAdvancedUpload)
const reset = async (user) => { if (!confirm(`重置 ${user.username} 的密码并注销其所有会话?`)) return; try { const result = await api(`/api/users/${user.id}/reset-password`, { method: 'POST' }); setSecret({ title: `${user.username} 的临时密码`, password: result.temporaryPassword }) } catch (error) { notify(error.message) } }
const remove = async (user) => { if (!confirm(`删除 ${user.username} 及其全部文件?此操作不可撤销。`)) return; try { await api(`/api/users/${user.id}`, { method: 'DELETE' }); await loadUsers(); notify('用户及其文件已删除') } catch (error) { notify(error.message) } }
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>{manageable(user) ? <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> : <span className="text-xs text-ink/35">权限高于你</span>}</div>)}</div>
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.canManageFiles && '文件管理', user.canUpload && '普通上传', user.canAdvancedUpload && '高级上传'].filter(Boolean).join(' · ') || '无权限'}</p></div>{manageable(user) ? <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> : <span className="text-xs text-ink/35">权限高于你</span>}</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 FileManagementPanel({ notify }) {
const files = useAppStore((s) => s.managedFiles)
const loadFiles = useAppStore((s) => s.loadManagedFiles)
const [loading, setLoading] = useState(true)
const refresh = async () => { setLoading(true); try { await loadFiles() } catch (error) { notify(error.message) } finally { setLoading(false) } }
useEffect(() => { refresh() }, [])
const remove = async (file) => {
if (!confirm(`删除文件「${file.name}」?此操作不可撤销。`)) return
try { await api(`/api/files/${file.id}`, { method: 'DELETE' }); await refresh(); notify('文件已删除') }
catch (error) { notify(error.message) }
}
const copy = async (file) => { await navigator.clipboard.writeText(`${location.origin}/uf/${file.hash}`); 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-secondary !py-2 text-sm" disabled={loading} onClick={refresh}>{loading ? <LoaderCircle className="animate-spin" size={15} /> : `${files.length} 个文件`}</button></div>
{loading && files.length === 0 ? <div className="grid place-items-center px-5 py-16"><LoaderCircle className="animate-spin text-leaf" /></div> : files.length === 0 ? <div className="px-5 py-16 text-center text-sm text-ink/40">当前没有有效文件</div> : <div className="divide-y divide-line">{files.map((file) => <div key={file.id} className="flex flex-col gap-4 p-5 lg:flex-row lg:items-center">
<div className="flex min-w-0 flex-1 items-start gap-3"><div className="grid size-10 shrink-0 place-items-center rounded-xl bg-paper text-leaf"><FileArchive size={18} /></div><div className="min-w-0"><p className="truncate font-medium" title={file.name}>{file.name}</p><button className="mt-1 font-mono text-xs text-ink/40 hover:text-leaf" onClick={() => copy(file)}>{file.hash} · {formatBytes(file.size)} · 点击复制链接</button></div></div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs text-ink/50 sm:grid-cols-4 lg:w-[520px]"><span><b className="block font-medium text-ink/70">上传者</b>{file.ownerUsername || `#${file.ownerId}`}</span><span><b className="block font-medium text-ink/70">失效时间</b>{formatDate(file.expiresAt)}</span><span><b className="block font-medium text-ink/70">访问次数</b>{file.maxDownloads == null ? '无限' : `${file.downloadCount} / ${file.maxDownloads}`}</span><span><b className="block font-medium text-ink/70">取件码</b>{file.hasPickupCode ? `已设置 · 失败 ${file.failedCodeAttempts}/5` : '未设置'}</span></div>
<button className="inline-flex items-center justify-center gap-1.5 rounded-xl px-3 py-2 text-sm font-medium text-red-600 hover:bg-red-50" onClick={() => remove(file)}><Trash2 size={15} />删除</button>
</div>)}</div>}
</section>
}
function Dashboard() {
const user = useAppStore((s) => s.user)
const logout = useAppStore((s) => s.logout)
@@ -200,8 +222,8 @@ function Dashboard() {
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 className="mx-auto max-w-6xl px-5 py-7"><div className="mb-7 flex w-fit max-w-full gap-1 overflow-x-auto rounded-xl bg-ink/5 p-1"><button className={`flex shrink-0 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.canManageFiles && <button className={`flex shrink-0 items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium ${tab === 'managed-files' ? 'bg-white shadow-sm' : 'text-ink/50'}`} onClick={() => setTab('managed-files')}><Files size={16} />文件管理</button>}{user.canManageUsers && <button className={`flex shrink-0 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> : tab === 'managed-files' ? <FileManagementPanel notify={notify} /> : <UsersPanel notify={notify} />}
</main>{toast && <Toast message={toast} onClose={() => setToast('')} />}
</div>
}
+3 -1
View File
@@ -5,6 +5,7 @@ export const useAppStore = create((set) => ({
user: null,
checking: true,
users: [],
managedFiles: [],
async checkSession() {
try { set({ user: await api('/api/auth/me') }) }
catch { set({ user: null }) }
@@ -16,8 +17,9 @@ export const useAppStore = create((set) => ({
},
async logout() {
await api('/api/auth/logout', { method: 'POST' })
set({ user: null, users: [] })
set({ user: null, users: [], managedFiles: [] })
},
setUser: (user) => set({ user }),
async loadUsers() { set({ users: await api('/api/users') }) },
async loadManagedFiles() { set({ managedFiles: await api('/api/files') }) },
}))