Implement coordinated frontend and backend updates

This commit is contained in:
yoyuzh
2026-04-12 11:36:13 +08:00
parent 9af2d38e37
commit 820e055d22
26 changed files with 7410 additions and 420 deletions

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
@@ -18,6 +19,7 @@
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.72.1",
"react-router-dom": "^7.14.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
@@ -1431,6 +1433,39 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3322,6 +3357,22 @@
"react": "^19.2.5"
}
},
"node_modules/react-hook-form": {
"version": "7.72.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-refresh": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-table": "^8.21.3",
"@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
@@ -21,6 +22,7 @@
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.72.1",
"react-router-dom": "^7.14.0",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"

View File

@@ -23,6 +23,7 @@ const AdminFilesList = lazy(() => import('./admin/files-list'));
const AdminFileBlobs = lazy(() => import('./admin/fileblobs'));
const AdminShares = lazy(() => import('./admin/shares'));
const AdminTasks = lazy(() => import('./admin/tasks'));
const AdminAudits = lazy(() => import('./admin/audits'));
const AdminOAuthApps = lazy(() => import('./admin/oauthapps'));
function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
@@ -55,6 +56,7 @@ function AnimatedRoutes({ isMobile }: { isMobile: boolean }) {
<Route path="file-blobs" element={<AdminFileBlobs />} />
<Route path="shares" element={<AdminShares />} />
<Route path="tasks" element={<AdminTasks />} />
<Route path="audits" element={<AdminAudits />} />
<Route path="oauth-apps" element={<AdminOAuthApps />} />
</Route>

View File

@@ -1,65 +1,88 @@
import { NavLink, Outlet } from 'react-router-dom';
import {
Activity,
Database,
FileBox,
Files,
HardDrive,
Key,
LayoutDashboard,
ListTodo,
Settings,
Share2,
Users
import {
Activity,
Database,
FileBox,
Files,
HardDrive,
Key,
LayoutDashboard,
ListTodo,
Settings,
Share2,
Users,
} from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { motion } from 'motion/react';
export default function AdminLayout() {
const adminNavItems = [
{ to: 'dashboard', icon: LayoutDashboard, label: '总览' },
{ to: 'settings', icon: Settings, label: '系统设置' },
{ to: 'filesystem', icon: HardDrive, label: '文件系统' },
{ to: 'storage-policies', icon: Database, label: '存储策略' },
{ to: 'users', icon: Users, label: '用户管理' },
{ to: 'files', icon: Files, label: '文件审计' },
{ to: 'file-blobs', icon: FileBox, label: '对象实体' },
{ to: 'shares', icon: Share2, label: '分享管理' },
{ to: 'tasks', icon: ListTodo, label: '任务监控' },
{ to: 'oauth-apps', icon: Key, label: '三方应用' },
const adminNavSections = [
{
title: '配置控制台',
items: [{ to: 'dashboard', icon: LayoutDashboard, label: '配置首页' }],
},
{
title: '核心配置',
items: [
{ to: 'settings', icon: Settings, label: '系统设置' },
{ to: 'storage-policies', icon: Database, label: '存储策略' },
{ to: 'users', icon: Users, label: '用户策略' },
{ to: 'filesystem', icon: HardDrive, label: '文件系统快照' },
],
},
{
title: '治理工具',
items: [
{ to: 'files', icon: Files, label: '文件治理' },
{ to: 'file-blobs', icon: FileBox, label: '对象治理' },
{ to: 'shares', icon: Share2, label: '分享治理' },
{ to: 'tasks', icon: ListTodo, label: '任务监控' },
{ to: 'audits', icon: Activity, label: '审计日志' },
],
},
{
title: '规划能力',
items: [{ to: 'oauth-apps', icon: Key, label: '三方应用' }],
},
];
return (
<div className="flex h-full w-full overflow-hidden">
{/* Admin Secondary Sidebar */}
<aside className="w-64 flex-shrink-0 border-r border-white/10 bg-white/5 dark:bg-black/20 flex flex-col z-10">
<div className="px-8 py-8">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-20"></p>
</div>
<nav className="flex-1 overflow-y-auto px-4 pb-8 space-y-1 custom-scrollbar">
{adminNavItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex items-center gap-3 px-4 py-3 rounded-lg text-xs font-black uppercase tracking-widest transition-all duration-300",
isActive
? "bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/10"
: "text-gray-700 dark:text-gray-200 hover:bg-white/10 dark:hover:bg-white/5 opacity-60 hover:opacity-100"
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
<nav className="flex-1 overflow-y-auto px-4 pb-8 custom-scrollbar">
{adminNavSections.map((section) => (
<div key={section.title} className="mb-7">
<div className="px-4 pb-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{section.title}</div>
<div className="space-y-1">
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-xs font-black uppercase tracking-widest transition-all duration-300',
isActive
? 'bg-blue-600/10 text-blue-600 dark:text-blue-400 shadow-sm border border-blue-500/10'
: 'text-gray-700 dark:text-gray-200 hover:bg-white/10 dark:hover:bg-white/5 opacity-60 hover:opacity-100',
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</div>
</div>
))}
</nav>
</aside>
{/* Admin Content Area */}
<main className="flex-1 overflow-hidden relative">
<motion.div
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="h-full w-full overflow-y-auto custom-scrollbar"

455
front/src/admin/audits.tsx Normal file
View File

@@ -0,0 +1,455 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, RefreshCw, Search } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { formatDateTime } from '@/src/lib/format';
import { getAdminAudits, type AdminAuditLog } from '@/src/lib/admin-audits';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 12, opacity: 0 },
show: { y: 0, opacity: 1 },
};
const DEFAULT_FILTERS = {
actorQuery: '',
actionType: '',
targetType: '',
targetId: '',
};
function titleBlock(title: string, subtitle: string) {
return (
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
</div>
);
}
function normalizeAuthorities(value: AdminAuditLog['actorAuthorities']) {
if (Array.isArray(value)) {
return value.filter(Boolean);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item).trim()).filter(Boolean);
}
} catch {
// Fall through to the plain-text splitter below.
}
}
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return [];
}
function formatDetailsJson(detailsJson: string | null) {
if (!detailsJson?.trim()) {
return '无详细内容';
}
try {
const parsed = JSON.parse(detailsJson);
return typeof parsed === 'string' ? parsed : JSON.stringify(parsed, null, 2);
} catch {
return detailsJson;
}
}
function actionPill(value: string) {
return (
<span className="inline-flex items-center rounded-full border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] text-blue-600 dark:text-blue-300">
{value || '-'}
</span>
);
}
function targetPill(type: string, targetId: string | null) {
return (
<div className="inline-flex flex-col gap-1">
<span className="inline-flex w-fit items-center rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-80">
{type || '-'}
</span>
<span className="font-mono text-[9px] font-black uppercase tracking-[0.16em] opacity-35">{targetId || '-'}</span>
</div>
);
}
export default function AdminAuditsPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [page, setPage] = useState<{
items: AdminAuditLog[];
total: number;
page: number;
size: number;
} | null>(null);
const [expandedAuditIds, setExpandedAuditIds] = useState<Set<number>>(() => new Set());
async function loadAudits(nextPage = 0, nextFilters = filters, isRefresh = false) {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError('');
try {
const result = await getAdminAudits(nextPage, 100, nextFilters);
setPage(result);
} catch (err) {
setError(err instanceof Error ? err.message : '加载审计日志失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
void loadAudits();
}, []);
async function copyText(value: string) {
try {
await navigator.clipboard.writeText(value);
} catch {
window.alert('复制失败,请手动复制。');
}
}
const items = page?.items ?? [];
const activeFilterLabels = useMemo(
() =>
[
filters.actorQuery.trim() ? `操作者: ${filters.actorQuery.trim()}` : '',
filters.actionType.trim() ? `动作: ${filters.actionType.trim()}` : '',
filters.targetType.trim() ? `目标类型: ${filters.targetType.trim()}` : '',
filters.targetId.trim() ? `目标 ID: ${filters.targetId.trim()}` : '',
].filter(Boolean),
[filters],
);
const isInitialLoading = loading && !page;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
`GET /api/admin/audits` / / / /
</p>
</div>
<button
type="button"
onClick={() => {
void loadAudits(page?.page ?? 0, filters, true);
}}
disabled={loading || refreshing}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
void loadAudits(0, filters);
}}
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
>
{titleBlock('筛选器', '只使用后端支持的查询参数,避免前端侧再做任何推断')}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_0.9fr_0.9fr_0.9fr]">
<label className="group relative block">
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
<input
value={filters.actorQuery}
onChange={(event) => setFilters((current) => ({ ...current, actorQuery: event.target.value }))}
placeholder="操作者关键词"
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<input
value={filters.actionType}
onChange={(event) => setFilters((current) => ({ ...current, actionType: event.target.value }))}
placeholder="动作类型"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<input
value={filters.targetType}
onChange={(event) => setFilters((current) => ({ ...current, targetType: event.target.value }))}
placeholder="目标类型"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<input
value={filters.targetId}
onChange={(event) => setFilters((current) => ({ ...current, targetId: event.target.value }))}
placeholder="目标 ID"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{activeFilterLabels.length ? (
activeFilterLabels.map((label) => (
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
{label}
</span>
))
) : (
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25"></span>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
setFilters(DEFAULT_FILTERS);
void loadAudits(0, DEFAULT_FILTERS);
}}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
>
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
>
</button>
</div>
</div>
</form>
{error ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">
{error}
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
<span> {page?.total ?? 0} </span>
<span> {items.length} </span>
<span>{page ? `${page.page + 1} 页 / 每页 ${page.size}` : '第 - 页'}</span>
</div>
<div className="flex-1 min-h-0">
{isInitialLoading ? (
<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 border border-white/10 shadow-3xl">
<div className="overflow-x-auto">
<table className="min-w-[1600px] divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 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">
{items.map((audit) => {
const authorities = normalizeAuthorities(audit.actorAuthorities);
const expanded = expandedAuditIds.has(audit.id);
return (
<Fragment key={audit.id}>
<motion.tr variants={itemVariants} className="group transition-colors hover:bg-white/10 dark:hover:bg-white/5">
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black tracking-tight">{formatDateTime(audit.createdAt)}</div>
<div className="mt-1 font-mono text-[9px] font-black uppercase tracking-[0.18em] opacity-35">ID {audit.id}</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[12px] font-black tracking-tight">{audit.actorUsername || '系统 / 未知'}</div>
<div className="mt-1 font-mono text-[9px] font-black uppercase tracking-[0.18em] opacity-35">
{audit.actorUserId != null ? `user #${audit.actorUserId}` : '无用户 ID'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{authorities.length ? (
authorities.map((authority) => (
<span
key={`${audit.id}-${authority}`}
className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-70"
>
{authority}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
</span>
)}
</div>
</td>
<td className="px-6 py-5 align-top">{actionPill(audit.actionType)}</td>
<td className="px-6 py-5 align-top">{targetPill(audit.targetType, audit.targetId)}</td>
<td className="px-6 py-5 align-top">
<div className="max-w-[560px] text-[11px] font-bold leading-6 opacity-90">{audit.summary || '-'}</div>
</td>
<td className="px-6 py-5 align-top text-right">
<button
type="button"
onClick={() => {
setExpandedAuditIds((current) => {
const next = new Set(current);
if (next.has(audit.id)) {
next.delete(audit.id);
} else {
next.add(audit.id);
}
return next;
});
}}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[9px] font-black uppercase tracking-[0.18em] transition-all hover:bg-white/10"
>
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
{expanded ? '收起详情' : '查看详情'}
</button>
</td>
</motion.tr>
{expanded ? (
<tr>
<td colSpan={6} className="px-6 pb-6">
<div className="rounded-lg border border-white/10 bg-white/5 p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h3>
<p className="mt-2 text-[11px] font-bold opacity-45">`detailsJson` </p>
</div>
<button
type="button"
onClick={() => void copyText(audit.detailsJson || '')}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[9px] font-black uppercase tracking-[0.18em] transition-all hover:bg-white/10"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<div className="rounded-lg border border-white/10 bg-black/10 p-4">
<div className="mb-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div>
<div className="space-y-3 text-[11px] font-bold leading-6">
<div>
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Summary</span>
{audit.summary || '-'}
</div>
<div>
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Action</span>
{audit.actionType || '-'}
</div>
<div>
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Target</span>
{audit.targetType || '-'} {audit.targetId ? `#${audit.targetId}` : ''}
</div>
<div>
<span className="mr-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Created</span>
{formatDateTime(audit.createdAt)}
</div>
</div>
</div>
<div>
<div className="mb-3 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">JSON </div>
<pre className="max-h-[420px] overflow-auto rounded-lg border border-white/10 bg-black/20 p-4 font-mono text-[11px] leading-6 text-gray-200 dark:text-gray-100">
{formatDetailsJson(audit.detailsJson)}
</pre>
</div>
</div>
</div>
</td>
</tr>
) : null}
</Fragment>
);
})}
{items.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-35">
</td>
</tr>
) : null}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
{page ? `${page.page + 1} 页 / 每页 ${page.size}` : '尚未加载分页信息'}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (!page || page.page <= 0) {
return;
}
void loadAudits(page.page - 1, filters);
}}
disabled={!page || page.page <= 0 || loading}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
if (!page || (page.page + 1) * page.size >= page.total) {
return;
}
void loadAudits(page.page + 1, filters);
}}
disabled={!page || (page.page + 1) * page.size >= page.total || loading}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
);
}

View File

@@ -1,8 +1,20 @@
import { useEffect, useState } from 'react';
import { Copy, Database, HardDrive, RefreshCw, Send, Users, ChevronRight, Activity } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { useEffect, useState, type ReactNode } from 'react';
import {
Activity,
ArrowRight,
Copy,
Database,
HardDrive,
RefreshCw,
Send,
Settings,
Share2,
Shield,
Users,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { getAdminSummary, type AdminSummary } from '@/src/lib/admin';
import { formatBytes } from '@/src/lib/format';
@@ -11,27 +23,107 @@ const container = {
show: {
opacity: 1,
transition: {
staggerChildren: 0.05
}
}
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 20, opacity: 0 },
show: { y: 0, opacity: 1 }
hidden: { y: 16, opacity: 0 },
show: { y: 0, opacity: 1 },
};
function ConfigCard({
to,
icon,
title,
description,
highlights,
tone,
}: {
to: string;
icon: ReactNode;
title: string;
description: string;
highlights: string[];
tone: 'blue' | 'emerald' | 'amber';
}) {
const toneClass =
tone === 'emerald'
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-500'
: tone === 'amber'
? 'border-amber-500/20 bg-amber-500/10 text-amber-500'
: 'border-blue-500/20 bg-blue-500/10 text-blue-500';
return (
<Link
to={to}
className="group glass-panel-no-hover rounded-2xl border border-white/10 p-7 shadow-3xl transition-all hover:border-white/20 hover:bg-white/10"
>
<div className="flex items-start justify-between gap-4">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-xl border', toneClass)}>{icon}</div>
<ArrowRight className="h-4 w-4 opacity-20 transition-all group-hover:translate-x-1 group-hover:opacity-100" />
</div>
<h2 className="mt-6 text-xl font-black tracking-tight text-gray-900 dark:text-white">{title}</h2>
<p className="mt-3 text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
<div className="mt-6 flex flex-wrap gap-2">
{highlights.map((item) => (
<span
key={item}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-75"
>
{item}
</span>
))}
</div>
</Link>
);
}
function ToolCard({
to,
icon,
title,
description,
}: {
to: string;
icon: ReactNode;
title: string;
description: string;
}) {
return (
<Link
to={to}
className="group rounded-xl border border-white/10 bg-white/5 p-5 transition-all hover:border-white/20 hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-white/10 bg-black/10 text-gray-700 dark:text-gray-100">
{icon}
</div>
<div>
<div className="text-[11px] font-black uppercase tracking-[0.18em]">{title}</div>
<div className="mt-1 text-[10px] font-bold leading-5 opacity-45">{description}</div>
</div>
</div>
<ArrowRight className="h-4 w-4 opacity-20 transition-all group-hover:translate-x-1 group-hover:opacity-100" />
</div>
</Link>
);
}
export default function AdminDashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [summary, setSummary] = useState<AdminSummary | null>(null);
const [copiedInviteCode, setCopiedInviteCode] = useState(false);
async function loadSummary() {
setError('');
try {
setSummary(await getAdminSummary());
} catch (err) {
setError(err instanceof Error ? err.message : '加载后台总览失败');
setError(err instanceof Error ? err.message : '加载配置首页失败');
} finally {
setLoading(false);
}
@@ -41,17 +133,29 @@ export default function AdminDashboard() {
void loadSummary();
}, []);
async function copyInviteCode(inviteCode: string) {
try {
await navigator.clipboard.writeText(inviteCode);
setCopiedInviteCode(true);
window.setTimeout(() => setCopiedInviteCode(false), 1500);
} catch {
setError('复制邀请码失败,请手动复制。');
}
}
return (
<motion.div
<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"
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex items-center justify-between">
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<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>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight 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"
@@ -59,145 +163,159 @@ export default function AdminDashboard() {
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"
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
<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}
{error ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">
{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>
<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>
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl">
<div className="grid gap-8 xl:grid-cols-[1.1fr_0.9fr]">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></div>
<h2 className="mt-4 text-3xl font-black tracking-tight text-gray-900 dark:text-white">
</h2>
<p className="mt-4 max-w-3xl text-sm leading-7 text-gray-600 dark:text-gray-300">
</p>
<div className="mt-6 flex flex-wrap gap-3">
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
: {summary.inviteCode}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
线: {formatBytes(summary.offlineTransferStorageLimitBytes)}
</span>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-75">
: {summary.totalUsers}
</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 className="rounded-2xl border border-white/10 bg-black/20 p-6">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30"></div>
<div className="mt-2 text-xl font-black tracking-[0.25em] text-blue-500">{summary.inviteCode}</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>
<button
type="button"
onClick={() => void copyInviteCode(summary.inviteCode)}
className="rounded-lg border border-white/10 bg-white/5 p-2.5 transition-colors hover:bg-white/10"
title="复制邀请码"
>
{copiedInviteCode ? <Shield className="h-4 w-4 text-emerald-500" /> : <Copy className="h-4 w-4" />}
</button>
</div>
<div className="mt-6 grid grid-cols-2 gap-4">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30">线</div>
<div className="mt-2 text-lg font-black tracking-tight">{formatBytes(summary.offlineTransferStorageBytes)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30"></div>
<div className="mt-2 text-lg font-black tracking-tight">{formatBytes(summary.downloadTrafficBytes)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30"></div>
<div className="mt-2 text-lg font-black tracking-tight">{summary.totalFiles}</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-30"></div>
<div className="mt-2 text-lg font-black tracking-tight">{summary.requestCount}</div>
</div>
</div>
</div>
</motion.section>
</div>
</div>
</motion.section>
<motion.section variants={itemVariants}>
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[10px] font-bold uppercase tracking-[0.2em] opacity-25"></p>
</div>
<div className="grid gap-6 xl:grid-cols-3">
<ConfigCard
to="/admin/settings"
icon={<Settings className="h-6 w-6" />}
title="系统配置"
description="集中处理邀请码、离线快传总容量,以及当前运行环境里最直接影响注册和传输行为的系统项。"
highlights={['邀请码', '离线快传上限', '运行快照']}
tone="blue"
/>
<ConfigCard
to="/admin/storage-policies"
icon={<Database className="h-6 w-6" />}
title="存储配置"
description="集中处理存储策略的新增、编辑、启停与迁移任务创建,不再把这块藏在资源表格里。"
highlights={['策略编辑', '启停', '迁移任务']}
tone="emerald"
/>
<ConfigCard
to="/admin/users"
icon={<Users className="h-6 w-6" />}
title="用户策略"
description="集中处理用户角色、配额、上传上限、手动改密和临时密码重置,把用户页从“查人”改成“改规则”。"
highlights={['角色', '配额', '上传上限', '密码策略']}
tone="amber"
/>
</div>
</motion.section>
<motion.section variants={itemVariants}>
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[10px] font-bold uppercase tracking-[0.2em] opacity-25"></p>
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ToolCard to="/admin/files" icon={<HardDrive className="h-5 w-5" />} title="文件治理" description="查全站文件、执行高风险删除。" />
<ToolCard to="/admin/file-blobs" icon={<Database className="h-5 w-5" />} title="对象治理" description="查 blob 关联、孤儿风险和对象异常。" />
<ToolCard to="/admin/shares" icon={<Share2 className="h-5 w-5" />} title="分享治理" description="排查 Token、撤销分享和过期风险。" />
<ToolCard to="/admin/tasks" icon={<Send className="h-5 w-5" />} title="任务监控" description="观察迁移和后台任务,不在这里改系统配置。" />
<ToolCard to="/admin/audits" icon={<Activity className="h-5 w-5" />} title="审计日志" description="复盘谁改了什么,而不是直接改值。" />
<ToolCard to="/admin/filesystem" icon={<HardDrive className="h-5 w-5" />} title="文件系统快照" description="查看当前文件与上传体系状态。" />
</div>
</motion.section>
<motion.section variants={itemVariants} className="grid gap-6 lg:grid-cols-3">
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500">
<Settings className="h-5 w-5" />
</div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30"></div>
<div className="mt-3 text-2xl font-black tracking-tight">{formatBytes(summary.offlineTransferStorageLimitBytes)}</div>
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45">线便</p>
</div>
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-emerald-500/20 bg-emerald-500/10 text-emerald-500">
<Database className="h-5 w-5" />
</div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30"></div>
<div className="mt-3 text-2xl font-black tracking-tight">{formatBytes(summary.totalStorageBytes)}</div>
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45"></p>
</div>
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-xl border border-amber-500/20 bg-amber-500/10 text-amber-500">
<Users className="h-5 w-5" />
</div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-30"></div>
<div className="mt-3 text-2xl font-black tracking-tight">{summary.totalUsers}</div>
<p className="mt-3 text-[10px] font-bold leading-6 opacity-45"></p>
</div>
</motion.section>
</motion.div>
) : null}
</motion.div>

View File

@@ -1 +1,482 @@
export default function AdminFileBlobs() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin FileBlobs ()</h1></div>; }
import { useEffect, useState, type ReactNode } from 'react';
import { AlertTriangle, CheckCircle2, Copy, FileBox, RefreshCw, Search, ShieldAlert, XCircle } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import {
getAdminFileBlobs,
type AdminFileBlobEntityType,
type AdminFileBlobResponse,
} from '@/src/lib/admin-fileblobs';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 12, opacity: 0 },
show: { y: 0, opacity: 1 },
};
const DEFAULT_FILTERS = {
userQuery: '',
storagePolicyId: '',
objectKey: '',
entityType: '' as AdminFileBlobEntityType | '',
};
const ENTITY_TYPE_LABELS: Record<AdminFileBlobEntityType, string> = {
VERSION: '版本',
THUMBNAIL: '缩略图',
LIVE_PHOTO: '实况照片',
TRANSCODE: '转码产物',
AVATAR: '头像',
};
function statusPill(active: boolean, trueLabel: string, falseLabel: string, tone: 'red' | 'amber' | 'purple' = 'red') {
const toneClass =
tone === 'amber'
? active
? 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
: tone === 'purple'
? active
? 'border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
: active
? 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em]',
toneClass,
)}
>
{active ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
{active ? trueLabel : falseLabel}
</span>
);
}
function titleBlock(title: string, subtitle: string) {
return (
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
</div>
);
}
function metricCard({
label,
value,
icon,
tone,
}: {
label: string;
value: string;
icon: ReactNode;
tone: 'blue' | 'green' | 'amber' | 'red' | 'purple';
}) {
return (
<div className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-2xl transition-all group hover:border-white/20">
<div
className={cn(
'mb-5 flex h-12 w-12 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]',
tone === 'blue' && 'bg-blue-500/10 border-blue-500/20 text-blue-500',
tone === 'green' && 'bg-green-500/10 border-green-500/20 text-green-500',
tone === 'purple' && 'bg-purple-500/10 border-purple-500/20 text-purple-500',
tone === 'amber' && 'bg-amber-500/10 border-amber-500/20 text-amber-500',
tone === 'red' && 'bg-red-500/10 border-red-500/20 text-red-500',
)}
>
{icon}
</div>
<h3 className="text-3xl font-black tracking-tight">{value}</h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{label}</p>
</div>
);
}
function riskRow(label: string, active: boolean, tone: 'red' | 'amber' | 'purple') {
return statusPill(active, label, `${label}`, tone);
}
export default function AdminFileBlobs() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [page, setPage] = useState<{
items: AdminFileBlobResponse[];
total: number;
page: number;
size: number;
} | null>(null);
async function loadFileBlobs(nextFilters = filters, isRefresh = false) {
const trimmedStoragePolicyId = nextFilters.storagePolicyId.trim();
if (trimmedStoragePolicyId) {
const parsedStoragePolicyId = Number(trimmedStoragePolicyId);
if (!Number.isInteger(parsedStoragePolicyId) || parsedStoragePolicyId <= 0) {
setError('存储策略 ID 必须是正整数');
return;
}
}
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError('');
try {
const result = await getAdminFileBlobs(0, 100, {
userQuery: nextFilters.userQuery,
storagePolicyId: trimmedStoragePolicyId ? Number(trimmedStoragePolicyId) : null,
objectKey: nextFilters.objectKey,
entityType: nextFilters.entityType,
});
setPage(result);
} catch (err) {
setError(err instanceof Error ? err.message : '加载对象实体失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
void loadFileBlobs();
}, []);
async function copyText(value: string) {
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
} catch {
window.alert('复制失败,请手动复制。');
}
}
const items = page?.items ?? [];
const blobMissingCount = items.filter((item) => item.blobMissing).length;
const orphanRiskCount = items.filter((item) => item.orphanRisk).length;
const referenceMismatchCount = items.filter((item) => item.referenceMismatch).length;
const anyRiskCount = items.filter((item) => item.blobMissing || item.orphanRisk || item.referenceMismatch).length;
const activeFilterLabels = [
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
filters.storagePolicyId.trim() ? `策略: #${filters.storagePolicyId.trim()}` : '',
filters.objectKey.trim() ? `对象键: ${filters.objectKey.trim()}` : '',
filters.entityType ? `实体类型: ${ENTITY_TYPE_LABELS[filters.entityType]}` : '',
].filter(Boolean);
const isInitialLoading = loading && !page;
const pageLabel = page ? `${page.page + 1} 页 / 每页 ${page.size}` : '-';
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
`GET /api/admin/file-blobs` / / /
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void loadFileBlobs(filters, true);
}}
disabled={loading || refreshing}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</button>
</div>
</div>
<motion.section variants={container} initial="hidden" animate="show" className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
<motion.div variants={itemVariants}>
{metricCard({
label: '当前页对象数',
value: page ? `${items.length}` : '-',
icon: <FileBox className="h-6 w-6" />,
tone: 'blue',
})}
</motion.div>
<motion.div variants={itemVariants}>
{metricCard({
label: 'blobMissing',
value: `${blobMissingCount}`,
icon: <XCircle className="h-6 w-6" />,
tone: 'red',
})}
</motion.div>
<motion.div variants={itemVariants}>
{metricCard({
label: 'orphanRisk',
value: `${orphanRiskCount}`,
icon: <AlertTriangle className="h-6 w-6" />,
tone: 'amber',
})}
</motion.div>
<motion.div variants={itemVariants}>
{metricCard({
label: 'referenceMismatch',
value: `${referenceMismatchCount}`,
icon: <ShieldAlert className="h-6 w-6" />,
tone: 'purple',
})}
</motion.div>
</motion.section>
<form
onSubmit={(event) => {
event.preventDefault();
void loadFileBlobs(filters);
}}
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
>
{titleBlock('筛选器', '只保留服务端支持的查询参数,避免前端做额外猜测')}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.2fr_0.8fr_1fr_0.8fr]">
<label className="group relative block">
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
<input
value={filters.userQuery}
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
placeholder="搜索用户名 / 邮箱 / 手机号"
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<input
value={filters.storagePolicyId}
onChange={(event) => setFilters((current) => ({ ...current, storagePolicyId: event.target.value }))}
inputMode="numeric"
placeholder="存储策略 ID"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<input
value={filters.objectKey}
onChange={(event) => setFilters((current) => ({ ...current, objectKey: event.target.value }))}
placeholder="对象键"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="group relative block">
<select
value={filters.entityType}
onChange={(event) =>
setFilters((current) => ({
...current,
entityType: event.target.value as AdminFileBlobEntityType | '',
}))
}
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
>
<option value=""></option>
{Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{activeFilterLabels.length ? (
activeFilterLabels.map((label) => (
<span
key={label}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70"
>
{label}
</span>
))
) : (
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25"></span>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
setFilters(DEFAULT_FILTERS);
void loadFileBlobs(DEFAULT_FILTERS);
}}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
>
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
>
</button>
</div>
</div>
</form>
{error ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">
{error}
</div>
) : null}
{page ? (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
<span>
{page.total} / {pageLabel}
</span>
<span> {anyRiskCount} </span>
</div>
) : null}
<div className="flex-1 min-h-0">
{isInitialLoading ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
...
</div>
) : page ? (
<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-[1600px] divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"> / Blob</th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"> / </th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 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">
{items.map((item) => {
const rowClassName = cn(
'group transition-colors',
item.blobMissing && 'bg-red-500/5 hover:bg-red-500/10',
!item.blobMissing && item.orphanRisk && 'bg-amber-500/5 hover:bg-amber-500/10',
!item.blobMissing && !item.orphanRisk && item.referenceMismatch && 'bg-purple-500/5 hover:bg-purple-500/10',
);
return (
<motion.tr key={`${item.entityId}-${item.blobId}`} variants={itemVariants} className={rowClassName}>
<td className="px-6 py-5 align-top">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="break-all font-mono text-[11px] font-black uppercase tracking-tight group-hover:text-blue-500">
{item.objectKey}
</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
{item.createdByUsername || `#${item.createdByUserId ?? '-'}`}
</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
{item.sampleOwnerUsername || '-'}
{item.sampleOwnerEmail ? ` / ${item.sampleOwnerEmail}` : ''}
</div>
</div>
<button
type="button"
onClick={() => void copyText(item.objectKey)}
className="rounded-lg border border-white/10 bg-white/5 p-2 text-blue-500 opacity-30 transition-all hover:bg-blue-600 hover:text-white group-hover:opacity-100"
title="复制对象键"
>
<Copy className="h-4 w-4" />
</button>
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black uppercase tracking-tight"> #{item.entityId}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">Blob #{item.blobId}</div>
<div className="mt-2 inline-flex rounded-full border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em] text-blue-600 dark:text-blue-400">
{ENTITY_TYPE_LABELS[item.entityType]}
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black uppercase tracking-tight"> #{item.storagePolicyId}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
{item.createdByUserId != null ? `创建者 ID ${item.createdByUserId}` : '创建者 ID -'}
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black uppercase tracking-tight">{formatBytes(item.size)}</div>
<div className="mt-1 break-words text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
{item.contentType || '-'}
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="space-y-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
<div> {item.referenceCount ?? '-'}</div>
<div> {item.linkedStoredFileCount}</div>
<div> {item.linkedOwnerCount}</div>
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="flex flex-col gap-2">
{riskRow('blobMissing', item.blobMissing, 'red')}
{riskRow('orphanRisk', item.orphanRisk, 'amber')}
{riskRow('referenceMismatch', item.referenceMismatch, 'purple')}
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[10px] font-bold uppercase tracking-tighter opacity-30">{formatDateTime(item.createdAt)}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
Blob {formatDateTime(item.blobCreatedAt)}
</div>
</td>
</motion.tr>
);
})}
{items.length === 0 ? (
<tr>
<td colSpan={7} 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 className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1 +1,327 @@
export default function AdminFilesystem() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Filesystem ()</h1></div>; }
import { useEffect, useState } from 'react';
import { CheckCircle2, Copy, Database, Globe, HardDrive, Layers3, RefreshCw, Server, ShieldCheck, XCircle } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { getAdminFilesystem, type AdminFilesystemResponse } from '@/src/lib/admin-filesystem';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 14, opacity: 0 },
show: { y: 0, opacity: 1 },
};
function statusClass(active: boolean) {
return active
? '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';
}
function statusIcon(active: boolean) {
return active ? <CheckCircle2 className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />;
}
function booleanLabel(active: boolean) {
return active ? '启用' : '停用';
}
function infoRow(label: string, value: string) {
return (
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
<div className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30">{label}</div>
<div className="mt-2 break-words text-[11px] font-black uppercase tracking-tight opacity-80">{value || '-'}</div>
</div>
);
}
function capabilityRow(label: string, active: boolean) {
return (
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50">{label}</span>
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(active))}>
{statusIcon(active)}
{booleanLabel(active)}
</span>
</div>
);
}
function sectionTitle(title: string, subtitle: string) {
return (
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
</div>
</div>
);
}
export default function AdminFilesystem() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filesystem, setFilesystem] = useState<AdminFilesystemResponse | null>(null);
async function loadFilesystem() {
setError('');
try {
setFilesystem(await getAdminFilesystem());
} catch (err) {
setError(err instanceof Error ? err.message : '加载文件系统信息失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadFilesystem();
}, []);
async function copyText(value: string) {
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
} catch {
window.alert('复制失败,请手动复制。');
}
}
const overviewCards = filesystem
? [
{
label: '存储提供者',
value: filesystem.overview.storageProvider,
icon: <Server className="h-7 w-7 text-blue-500" />,
tone: 'blue',
},
{
label: '文件总数',
value: String(filesystem.overview.totalFiles),
icon: <HardDrive className="h-7 w-7 text-green-500" />,
tone: 'green',
},
{
label: '对象总数',
value: String(filesystem.overview.totalBlobs),
icon: <Database className="h-7 w-7 text-purple-500" />,
tone: 'purple',
},
{
label: '实体总数',
value: String(filesystem.overview.totalEntities),
icon: <Layers3 className="h-7 w-7 text-amber-500" />,
tone: 'amber',
},
]
: [];
const capabilityEntries = filesystem
? [
['直传', filesystem.defaultPolicy.capabilities.directUpload],
['分片上传', filesystem.defaultPolicy.capabilities.multipartUpload],
['签名下载', filesystem.defaultPolicy.capabilities.signedDownloadUrl],
['服务端代理下载', filesystem.defaultPolicy.capabilities.serverProxyDownload],
['原生缩略图', filesystem.defaultPolicy.capabilities.thumbnailNative],
['友好下载名', filesystem.defaultPolicy.capabilities.friendlyDownloadName],
['需要 CORS', filesystem.defaultPolicy.capabilities.requiresCors],
['内部端点', filesystem.defaultPolicy.capabilities.supportsInternalEndpoint],
] as const
: [];
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex items-center justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / / / / WebDAV</p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadFilesystem();
}}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 font-black text-[11px] uppercase tracking-widest transition-all hover:bg-white/40"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</button>
</div>
{error ? <div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">{error}</div> : null}
{loading && !filesystem ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">...</div>
) : filesystem ? (
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
<motion.section variants={itemVariants} className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((card) => (
<div key={card.label} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-2xl transition-all group hover:border-white/20">
<div className={cn('mb-6 flex h-14 w-14 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]', card.tone === 'green' && 'bg-green-500/10 border-green-500/20', card.tone === 'purple' && 'bg-purple-500/10 border-purple-500/20', card.tone === 'amber' && 'bg-amber-500/10 border-amber-500/20', card.tone === 'blue' && 'bg-blue-500/10 border-blue-500/20')}>
{card.icon}
</div>
<h3 className="text-4xl font-black tracking-tight">{card.value}</h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{card.label}</p>
</div>
))}
</motion.section>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-[1.35fr_0.65fr]">
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-10 shadow-3xl">
{sectionTitle('默认存储策略', '当前系统选择的默认分发与对象存储规则')}
<div className="mb-6 flex flex-wrap items-center gap-3">
<span className={cn('inline-flex items-center gap-2 rounded-sm border px-2.5 py-1 text-[9px] font-black uppercase tracking-widest', filesystem.defaultPolicy.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')}>
{statusIcon(filesystem.defaultPolicy.enabled)}
{filesystem.defaultPolicy.enabled ? '默认策略启用' : '默认策略停用'}
</span>
<span className="rounded-sm border border-blue-500/20 bg-blue-500/10 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">{filesystem.defaultPolicy.defaultPolicy ? 'DEFAULT' : 'NON-DEFAULT'}</span>
<button
type="button"
onClick={() => void copyText(filesystem.defaultPolicy.endpoint || filesystem.defaultPolicy.bucketName || filesystem.defaultPolicy.name)}
className="inline-flex items-center gap-1.5 rounded-sm border border-white/10 bg-white/5 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest opacity-70 transition-all hover:bg-white/10 hover:opacity-100"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{infoRow('ID', String(filesystem.defaultPolicy.id))}
{infoRow('名称', filesystem.defaultPolicy.name)}
{infoRow('类型', filesystem.defaultPolicy.type)}
{infoRow('访问模式', filesystem.defaultPolicy.privateBucket ? '私有桶' : '公开桶')}
{infoRow('Bucket', filesystem.defaultPolicy.bucketName || '-')}
{infoRow('Endpoint', filesystem.defaultPolicy.endpoint || '-')}
{infoRow('Region', filesystem.defaultPolicy.region || '-')}
{infoRow('Prefix', filesystem.defaultPolicy.prefix || '-')}
{infoRow('凭证模式', filesystem.defaultPolicy.credentialMode)}
{infoRow('策略上限', formatBytes(filesystem.defaultPolicy.maxSizeBytes))}
{infoRow('创建时间', formatDateTime(filesystem.defaultPolicy.createdAt))}
{infoRow('更新时间', formatDateTime(filesystem.defaultPolicy.updatedAt))}
</div>
<div className="mt-8">
<div className="mb-4 text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{capabilityEntries.map(([label, active]) => capabilityRow(label, active))}
</div>
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50"></span>
<span className="text-[11px] font-black uppercase tracking-tight">{formatBytes(filesystem.defaultPolicy.capabilities.maxObjectSize)}</span>
</div>
</div>
</div>
</motion.section>
<div className="space-y-8">
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
{sectionTitle('上传模式矩阵', '前端只展示服务端暴露的实际可用上传路径')}
<div className="space-y-3">
{[
{ label: '代理上传', active: filesystem.upload.proxyUpload, note: '客户端经由后端转发,适合受控或兼容性场景。' },
{ label: '直传单文件', active: filesystem.upload.directSingleUpload, note: '单文件直接命中存储端,适合小文件快速上传。' },
{ label: '直传分片', active: filesystem.upload.directMultipartUpload, note: '大文件分片写入,适合稳定传输与断点续传。' },
].map((item) => (
<div key={item.label} className="rounded-lg border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">{item.label}</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">{item.note}</div>
</div>
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(item.active))}>
{statusIcon(item.active)}
{booleanLabel(item.active)}
</span>
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] font-black uppercase tracking-[0.18em] opacity-50"></span>
<span className="text-[11px] font-black uppercase tracking-tight">{formatBytes(filesystem.upload.effectiveMaxFileSizeBytes)}</span>
</div>
</div>
</motion.section>
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
{sectionTitle('媒体处理', '缩略图与元数据采集能力快照')}
<div className="space-y-3">
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50"></div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25"></div>
</div>
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.mediaProcessing.metadataExtractionEnabled))}>
{statusIcon(filesystem.mediaProcessing.metadataExtractionEnabled)}
{booleanLabel(filesystem.mediaProcessing.metadataExtractionEnabled)}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50"></div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25"></div>
</div>
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.mediaProcessing.nativeThumbnailSupport))}>
{statusIcon(filesystem.mediaProcessing.nativeThumbnailSupport)}
{booleanLabel(filesystem.mediaProcessing.nativeThumbnailSupport)}
</span>
</div>
</div>
</motion.section>
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
{sectionTitle('缓存状态', '文件列表与目录版本的缓存后端')}
<div className="grid grid-cols-1 gap-4">
{infoRow('缓存后端', filesystem.cache.backend)}
{infoRow('文件列表 TTL', `${filesystem.cache.filesListTtlSeconds}`)}
{infoRow('目录版本 TTL', `${filesystem.cache.directoryVersionTtlSeconds}`)}
</div>
</motion.section>
<motion.section variants={itemVariants} className="glass-panel-no-hover rounded-lg border border-white/10 p-8 shadow-3xl">
{sectionTitle('WebDAV 状态', '只读挂载与外部客户端访问能力')}
<div className="flex items-start justify-between gap-4 rounded-lg border border-white/10 bg-white/5 p-4">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.22em] opacity-50">WebDAV </div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25"> WebDAV</div>
</div>
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[9px] font-black uppercase tracking-widest', statusClass(filesystem.webdav.enabled))}>
{filesystem.webdav.enabled ? <Globe className="h-3.5 w-3.5" /> : <XCircle className="h-3.5 w-3.5" />}
{booleanLabel(filesystem.webdav.enabled)}
</span>
</div>
<div className="mt-4 rounded-lg border border-white/10 bg-white/5 p-4">
<div className="flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-50">
<ShieldCheck className="h-4 w-4 text-blue-500" />
</div>
<p className="mt-3 text-[11px] font-bold leading-6 opacity-60">
</p>
</div>
</motion.section>
</div>
</div>
</motion.div>
) : null}
</motion.div>
);
}

View File

@@ -1 +1,258 @@
export default function AdminOAuthApps() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin OAuthApps ()</h1></div>; }
import type { ReactNode } from 'react';
import { ArrowRight, Ban, CheckCircle2, KeyRound, ShieldAlert, Sparkles } from 'lucide-react';
import { motion } from 'motion/react';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.06,
},
},
};
const itemVariants = {
hidden: { y: 16, opacity: 0 },
show: { y: 0, opacity: 1 },
};
function SectionTitle({
eyebrow,
title,
description,
}: {
eyebrow: string;
title: string;
description: string;
}) {
return (
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{eyebrow}</h2>
<h3 className="mt-3 text-2xl font-black tracking-tight text-gray-900 dark:text-white">{title}</h3>
<p className="mt-3 max-w-3xl text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
</div>
);
}
function Badge({
children,
tone = 'neutral',
}: {
children: string;
tone?: 'neutral' | 'warning' | 'success' | 'info';
}) {
const toneClasses = {
neutral: 'border-white/10 bg-white/5 text-gray-600 dark:text-gray-300',
warning: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300',
success: 'border-green-500/20 bg-green-500/10 text-green-700 dark:text-green-300',
info: 'border-blue-500/20 bg-blue-500/10 text-blue-700 dark:text-blue-300',
}[tone];
return (
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-[9px] font-black uppercase tracking-[0.22em] ${toneClasses}`}
>
{children}
</span>
);
}
function InfoCard({
title,
description,
icon,
tone = 'blue',
}: {
title: string;
description: string;
icon: ReactNode;
tone?: 'blue' | 'amber' | 'green' | 'violet';
}) {
const ringClasses = {
blue: 'border-blue-500/20 bg-blue-500/10 text-blue-500',
amber: 'border-amber-500/20 bg-amber-500/10 text-amber-500',
green: 'border-green-500/20 bg-green-500/10 text-green-500',
violet: 'border-violet-500/20 bg-violet-500/10 text-violet-500',
}[tone];
return (
<motion.section
variants={itemVariants}
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
>
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border ${ringClasses}`}>
{icon}
</div>
<div>
<h4 className="text-[13px] font-black uppercase tracking-[0.18em] text-gray-900 dark:text-white">{title}</h4>
<p className="mt-2 text-sm leading-7 text-gray-600 dark:text-gray-300">{description}</p>
</div>
</div>
</motion.section>
);
}
export default function AdminOAuthApps() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white">
</h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
/ /
</p>
</div>
<Badge tone="warning"></Badge>
</div>
<motion.section
variants={container}
initial="hidden"
animate="show"
className="grid grid-cols-1 gap-6 xl:grid-cols-12"
>
<motion.div
variants={itemVariants}
className="xl:col-span-7 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
>
<SectionTitle
eyebrow="当前状态"
title="后端尚未提供 OAuth 应用管理接口"
description="页面现在只做状态说明,不接入任何写接口,也不展示虚假的配置表单。等 `/api/admin/oauth-apps` 及相关校验、审计、回调配置能力上线后,这里再开放真正的管理操作。"
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
<ShieldAlert className="h-4 w-4 text-amber-500" />
</div>
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
OAuth API
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
<Ban className="h-4 w-4 text-red-500" />
</div>
<p className="text-sm leading-7 text-gray-700 dark:text-gray-200">
</p>
</div>
</div>
</motion.div>
<motion.aside
variants={itemVariants}
className="xl:col-span-5 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
>
<div className="mb-6 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500">
<KeyRound className="h-5 w-5" />
</div>
<div>
<h2 className="text-[13px] font-black uppercase tracking-[0.18em] text-gray-900 dark:text-white">
</h2>
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.24em] opacity-30">
</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-white/10 bg-black/20 p-5">
<div className="mb-3 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.22em] opacity-35">
<Sparkles className="h-4 w-4 text-blue-500" />
</div>
<ul className="space-y-3 text-sm leading-7 text-gray-700 dark:text-gray-200">
<li className="flex gap-2">
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
OAuth
</li>
<li className="flex gap-2">
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
Client ID / Client Secret
</li>
<li className="flex gap-2">
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
</li>
<li className="flex gap-2">
<CheckCircle2 className="mt-1 h-4 w-4 shrink-0 text-green-500" />
</li>
</ul>
</div>
<button
type="button"
disabled
aria-disabled="true"
className="flex w-full items-center justify-between rounded-xl border border-dashed border-white/10 bg-white/5 px-5 py-4 text-left opacity-60"
>
<div>
<div className="text-[11px] font-black uppercase tracking-[0.2em] text-gray-900 dark:text-white">
API
</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
</div>
</div>
<ArrowRight className="h-4 w-4 shrink-0" />
</button>
</div>
</motion.aside>
<motion.section
variants={itemVariants}
className="xl:col-span-12 glass-panel-no-hover rounded-2xl border border-white/10 p-8 shadow-3xl"
>
<SectionTitle
eyebrow="后续能力"
title="后端上线后,这里会逐步开放的内容"
description="这一页会先作为路线图,等管理员接口稳定后再切回真正的操作界面。前端会直接绑定真实接口,并补齐权限、校验和反馈。"
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<InfoCard
title="应用登记"
description="支持录入应用名称、回调地址、主页地址、描述和启用状态,形成可追踪的应用清单。"
icon={<KeyRound className="h-5 w-5" />}
/>
<InfoCard
title="凭据管理"
description="支持生成、复制、轮换和失效 Client Secret并对敏感凭据做受控展示。"
icon={<ShieldAlert className="h-5 w-5" />}
tone="amber"
/>
<InfoCard
title="授权范围"
description="支持配置 OAuth scope、授权类型和回调白名单避免把权限交给不该拿到的应用。"
icon={<Sparkles className="h-5 w-5" />}
tone="violet"
/>
<InfoCard
title="审计追踪"
description="记录每一次增删改、密钥轮换和回调配置变更,方便排查和安全复核。"
icon={<CheckCircle2 className="h-5 w-5" />}
tone="green"
/>
</div>
</motion.section>
</motion.section>
</motion.div>
);
}

View File

@@ -1 +1,620 @@
export default function AdminSettings() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Settings ()</h1></div>; }
import { useEffect, useState, type ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { Copy, Database, RefreshCw, RotateCcw, Save, Server, Settings, Shield, Clock3, Layers3 } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import {
getAdminSettings,
rotateAdminRegistrationInviteCode,
updateAdminOfflineTransferStorageLimit,
updateAdminRegistrationInviteCode,
type AdminSettings,
} from '@/src/lib/admin-settings';
import { formatBytes } from '@/src/lib/format';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.06,
},
},
};
const itemVariants = {
hidden: { y: 18, opacity: 0 },
show: { y: 0, opacity: 1 },
};
function formatDurationSeconds(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) {
return '-';
}
if (seconds < 60) {
return `${seconds}`;
}
const minutes = seconds / 60;
if (minutes < 60) {
return `${minutes % 1 === 0 ? minutes : minutes.toFixed(1)} 分钟`;
}
const hours = minutes / 60;
if (hours < 24) {
return `${hours % 1 === 0 ? hours : hours.toFixed(1)} 小时`;
}
const days = hours / 24;
return `${days % 1 === 0 ? days : days.toFixed(1)}`;
}
function formatDurationMs(milliseconds: number) {
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
return '-';
}
if (milliseconds < 1000) {
return `${milliseconds} 毫秒`;
}
return formatDurationSeconds(milliseconds / 1000);
}
function statusPill(value: boolean, trueLabel = '已启用', falseLabel = '未启用') {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em]',
value
? 'border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300',
)}
>
{value ? trueLabel : falseLabel}
</span>
);
}
function SnapshotRow({
label,
value,
valueClassName,
}: {
label: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-white/10 py-3 last:border-0 last:pb-0">
<span className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">{label}</span>
<span className={cn('text-right text-[11px] font-bold leading-5', valueClassName)}>{value}</span>
</div>
);
}
function SnapshotCard({
title,
badge,
icon,
children,
}: {
title: string;
badge: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<motion.section
variants={itemVariants}
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-blue-500 shadow-inner">
{icon}
</div>
<div>
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]">{title}</h3>
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.3em] opacity-30">{badge}</p>
</div>
</div>
</div>
<div>{children}</div>
</motion.section>
);
}
type InviteCodeFormValues = {
inviteCode: string;
};
type OfflineTransferLimitFormValues = {
offlineTransferStorageLimitBytes: number;
};
export default function AdminSettingsPage() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [savingInviteCode, setSavingInviteCode] = useState(false);
const [rotatingInviteCode, setRotatingInviteCode] = useState(false);
const [savingTransferLimit, setSavingTransferLimit] = useState(false);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [settings, setSettings] = useState<AdminSettings | null>(null);
const inviteCodeForm = useForm<InviteCodeFormValues>({
defaultValues: {
inviteCode: '',
},
mode: 'onSubmit',
reValidateMode: 'onChange',
});
const offlineTransferLimitForm = useForm<OfflineTransferLimitFormValues>({
defaultValues: {
offlineTransferStorageLimitBytes: 1,
},
mode: 'onSubmit',
reValidateMode: 'onChange',
});
async function loadSettings(isRefresh = false) {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError('');
try {
const nextSettings = await getAdminSettings();
setSettings(nextSettings);
inviteCodeForm.reset({
inviteCode: nextSettings.registration.currentInviteCode,
});
offlineTransferLimitForm.reset({
offlineTransferStorageLimitBytes: nextSettings.transfer.offlineTransferStorageLimitBytes,
});
} catch (err) {
setError(err instanceof Error ? err.message : '加载系统设置失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
void loadSettings();
}, []);
async function handleSaveInviteCode(values: InviteCodeFormValues) {
const nextInviteCode = values.inviteCode.trim();
if (!nextInviteCode) {
setError('邀请码不能为空');
return;
}
setSavingInviteCode(true);
setError('');
setNotice('');
try {
await updateAdminRegistrationInviteCode(nextInviteCode);
await loadSettings(true);
setNotice('邀请码已更新');
} catch (err) {
setError(err instanceof Error ? err.message : '更新邀请码失败');
} finally {
setSavingInviteCode(false);
}
}
async function handleRotateInviteCode() {
if (!window.confirm('确定要轮换邀请码吗?旧邀请码会立即失效。')) {
return;
}
setRotatingInviteCode(true);
setError('');
setNotice('');
try {
await rotateAdminRegistrationInviteCode();
await loadSettings(true);
setNotice('邀请码已轮换');
} catch (err) {
setError(err instanceof Error ? err.message : '轮换邀请码失败');
} finally {
setRotatingInviteCode(false);
}
}
async function handleSaveTransferLimit(values: OfflineTransferLimitFormValues) {
const nextLimit = values.offlineTransferStorageLimitBytes;
if (!Number.isInteger(nextLimit) || nextLimit <= 0) {
setError('离线快传存储上限必须是大于 0 的整数');
return;
}
setSavingTransferLimit(true);
setError('');
setNotice('');
try {
await updateAdminOfflineTransferStorageLimit(nextLimit);
await loadSettings(true);
setNotice('离线快传存储上限已更新');
} catch (err) {
setError(err instanceof Error ? err.message : '更新离线快传存储上限失败');
} finally {
setSavingTransferLimit(false);
}
}
const isBusy = loading || refreshing;
const watchedOfflineLimit = offlineTransferLimitForm.watch('offlineTransferStorageLimitBytes');
const offlineLimitPreview = Number.isFinite(watchedOfflineLimit) ? watchedOfflineLimit : 0;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight 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={() => {
void loadSettings(true);
}}
disabled={isBusy}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</button>
</div>
{error ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 backdrop-blur-md dark:text-red-400">
{error}
</div>
) : null}
{notice ? (
<div className="mb-8 rounded-lg border border-blue-500/20 bg-blue-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-blue-600 backdrop-blur-md dark:text-blue-300">
{notice}
</div>
) : null}
{loading && !settings ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
...
</div>
) : settings ? (
<motion.div variants={container} initial="hidden" animate="show" className="space-y-10">
<section>
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[11px] font-bold opacity-40">
线
</p>
</div>
<span className="rounded-full border border-green-500/20 bg-green-500/10 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] text-green-600 dark:text-green-400">
PATCH / POST
</span>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<motion.section
variants={itemVariants}
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-blue-500/20 bg-blue-500/10 text-blue-500 shadow-inner">
<Shield className="h-5 w-5" />
</div>
<div>
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]"></h3>
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
</p>
</div>
</div>
{settings.registration.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')}
</div>
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30"></div>
<div className="text-sm font-black">{settings.registration.inviteCodeRequired ? '是' : '否'}</div>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30"></div>
<div className="flex flex-wrap gap-2">
{settings.registration.managementRoles.map((role) => (
<span
key={role}
className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[9px] font-black uppercase tracking-[0.2em]"
>
{role}
</span>
))}
</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30"></span>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(settings.registration.currentInviteCode);
setNotice('邀请码已复制');
} catch {
setError('复制邀请码失败');
}
}}
className="rounded-lg p-2 text-blue-500 transition-all hover:bg-white/10 hover:text-blue-400"
title="复制邀请码"
>
<Copy className="h-4 w-4" />
</button>
</div>
<div className="break-all rounded-xl border border-white/10 bg-blue-500/5 px-4 py-4 font-mono text-lg font-black tracking-[0.3em] text-blue-500">
{settings.registration.currentInviteCode}
</div>
</div>
<form
className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_auto]"
onSubmit={inviteCodeForm.handleSubmit(handleSaveInviteCode, () => {
setError('');
})}
>
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
</span>
<input
{...inviteCodeForm.register('inviteCode', {
required: '邀请码不能为空',
maxLength: {
value: 64,
message: '邀请码不能超过 64 个字符',
},
validate: (value) => value.trim().length > 0 || '邀请码不能为空',
})}
maxLength={64}
placeholder="输入新的邀请码"
aria-invalid={inviteCodeForm.formState.errors.inviteCode ? 'true' : 'false'}
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.25em] outline-none transition-all placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
{inviteCodeForm.formState.errors.inviteCode ? (
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
{inviteCodeForm.formState.errors.inviteCode.message}
</div>
) : null}
</label>
<div className="flex items-end gap-3">
<button
type="submit"
disabled={savingInviteCode || rotatingInviteCode}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] text-white shadow-lg transition-all hover:bg-blue-500 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
{savingInviteCode ? '保存中' : '保存'}
</button>
<button
type="button"
onClick={handleRotateInviteCode}
disabled={savingInviteCode || rotatingInviteCode}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/5 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] transition-all hover:bg-white/15 disabled:cursor-not-allowed disabled:opacity-60"
>
<RotateCcw className={cn('h-4 w-4', rotatingInviteCode && 'animate-spin')} />
{rotatingInviteCode ? '轮换中' : '轮换'}
</button>
</div>
</form>
</div>
</motion.section>
<motion.section
variants={itemVariants}
className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl"
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-amber-500/20 bg-amber-500/10 text-amber-500 shadow-inner">
<Database className="h-5 w-5" />
</div>
<div>
<h3 className="text-[13px] font-black uppercase tracking-[0.18em]">线</h3>
<p className="mt-1 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
线
</p>
</div>
</div>
{settings.transfer.writeSupported ? statusPill(true, '可编辑', '只读') : statusPill(false, '可编辑', '只读')}
</div>
<div className="space-y-5">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30"></div>
<div className="text-3xl font-black tracking-tight text-amber-500">
{formatBytes(settings.transfer.offlineTransferStorageLimitBytes)}
</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
{settings.transfer.offlineTransferStorageLimitBytes}
</div>
</div>
<form
className="space-y-5"
onSubmit={offlineTransferLimitForm.handleSubmit(handleSaveTransferLimit, () => {
setError('');
})}
>
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
</span>
<input
type="number"
min={1}
step={1}
{...offlineTransferLimitForm.register('offlineTransferStorageLimitBytes', {
valueAsNumber: true,
required: '离线快传存储上限不能为空',
validate: (value) =>
Number.isInteger(value) && value > 0 ? true : '离线快传存储上限必须是大于 0 的整数',
})}
aria-invalid={offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? 'true' : 'false'}
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-4 font-mono text-[12px] font-black tracking-[0.2em] outline-none transition-all placeholder:opacity-20 focus:border-amber-500/50 focus:ring-4 focus:ring-amber-500/10"
/>
{offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes ? (
<div className="mt-2 text-[10px] font-bold uppercase tracking-[0.15em] text-red-500 dark:text-red-400">
{offlineTransferLimitForm.formState.errors.offlineTransferStorageLimitBytes.message}
</div>
) : null}
</label>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-amber-500/10 bg-amber-500/5 px-4 py-4">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.25em] opacity-30"></div>
<div className="mt-1 text-sm font-black">{formatBytes(offlineLimitPreview)}</div>
</div>
<button
type="submit"
disabled={savingTransferLimit || savingInviteCode || rotatingInviteCode}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-amber-500 px-5 py-4 text-[11px] font-black uppercase tracking-[0.15em] text-white shadow-lg transition-all hover:bg-amber-400 hover:scale-[1.02] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
{savingTransferLimit ? '保存中' : '保存上限'}
</button>
</div>
</form>
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-2 flex items-center gap-2 text-[9px] font-black uppercase tracking-[0.25em] opacity-30">
<Clock3 className="h-3.5 w-3.5" />
</div>
<div className="text-[11px] font-bold leading-6 opacity-70">
线
</div>
</div>
</div>
</motion.section>
</div>
</section>
<section>
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[11px] font-bold opacity-40">
<span className="font-mono">GET /api/admin/settings</span>
</p>
</div>
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
Snapshot
</span>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
<SnapshotCard
title="站点设置"
badge={settings.site.writeSupported ? '可写快照' : '只读快照'}
icon={<Settings className="h-5 w-5" />}
>
<SnapshotRow label="是否支持" value={statusPill(settings.site.supported, '已接入', '未接入')} />
<SnapshotRow label="写入支持" value={statusPill(settings.site.writeSupported, '可写', '只读')} />
</SnapshotCard>
<SnapshotCard
title="用户会话"
badge={settings.userSession.writeSupported ? '可写快照' : '只读快照'}
icon={<Shield className="h-5 w-5" />}
>
<SnapshotRow label="Access TTL" value={formatDurationSeconds(settings.userSession.accessExpirationSeconds)} />
<SnapshotRow label="Refresh TTL" value={formatDurationSeconds(settings.userSession.refreshExpirationSeconds)} />
<SnapshotRow
label="Token 黑名单"
value={statusPill(settings.userSession.tokenBlacklistEnabled, '已启用', '未启用')}
/>
<SnapshotRow
label="黑名单缓冲"
value={formatDurationSeconds(settings.userSession.tokenBlacklistTtlBufferSeconds)}
/>
<SnapshotRow label="写入支持" value={statusPill(settings.userSession.writeSupported, '可写', '只读')} />
</SnapshotCard>
<SnapshotCard
title="媒体处理"
badge={settings.mediaProcessing.writeSupported ? '可写快照' : '只读快照'}
icon={<Server className="h-5 w-5" />}
>
<SnapshotRow
label="元数据提取"
value={statusPill(settings.mediaProcessing.metadataExtractionEnabled, '已启用', '未启用')}
/>
<SnapshotRow
label="缩略图生成"
value={statusPill(settings.mediaProcessing.thumbnailGenerationEnabled, '已启用', '未启用')}
/>
<SnapshotRow
label="视频封面"
value={statusPill(settings.mediaProcessing.videoPosterEnabled, '已启用', '未启用')}
/>
<SnapshotRow label="写入支持" value={statusPill(settings.mediaProcessing.writeSupported, '可写', '只读')} />
</SnapshotCard>
<SnapshotCard
title="任务队列"
badge={settings.queue.writeSupported ? '可写快照' : '只读快照'}
icon={<Clock3 className="h-5 w-5" />}
>
<SnapshotRow label="后端" value={settings.queue.backend} valueClassName="font-mono uppercase tracking-[0.2em]" />
<SnapshotRow label="固定延迟" value={formatDurationMs(settings.queue.mediaMetadataFixedDelayMs)} />
<SnapshotRow label="初始延迟" value={formatDurationMs(settings.queue.mediaMetadataInitialDelayMs)} />
<SnapshotRow label="写入支持" value={statusPill(settings.queue.writeSupported, '可写', '只读')} />
</SnapshotCard>
<SnapshotCard
title="外观"
badge={settings.appearance.writeSupported ? '可写快照' : '只读快照'}
icon={<Layers3 className="h-5 w-5" />}
>
<SnapshotRow label="是否支持" value={statusPill(settings.appearance.supported, '已接入', '未接入')} />
<SnapshotRow label="写入支持" value={statusPill(settings.appearance.writeSupported, '可写', '只读')} />
</SnapshotCard>
<SnapshotCard
title="服务器"
badge={settings.server.writeSupported ? '可写快照' : '只读快照'}
icon={<Server className="h-5 w-5" />}
>
<SnapshotRow
label="存储提供者"
value={settings.server.storageProvider}
valueClassName="font-mono uppercase tracking-[0.2em]"
/>
<SnapshotRow label="Redis" value={statusPill(settings.server.redisEnabled, '已启用', '未启用')} />
<SnapshotRow label="写入支持" value={statusPill(settings.server.writeSupported, '可写', '只读')} />
</SnapshotCard>
</div>
</section>
</motion.div>
) : null}
</motion.div>
);
}

View File

@@ -1 +1,463 @@
export default function AdminShares() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Shares ()</h1></div>; }
import { useEffect, useState } from 'react';
import { Copy, ExternalLink, RefreshCw, Search, Trash2 } from 'lucide-react';
import { motion } from 'motion/react';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowData,
} from '@tanstack/react-table';
import { cn } from '@/src/lib/utils';
import { formatBytes, formatDateTime } from '@/src/lib/format';
import { deleteAdminShare, getAdminShares, type AdminShare } from '@/src/lib/admin-shares';
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
thClassName?: string;
tdClassName?: string;
}
}
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 10, opacity: 0 },
show: { y: 0, opacity: 1 },
};
const DEFAULT_FILTERS = {
userQuery: '',
fileName: '',
token: '',
passwordProtected: '' as 'true' | 'false' | '',
expired: '' as 'true' | 'false' | '',
};
function boolBadge(active: boolean, activeLabel: string, inactiveLabel: string, tone: 'blue' | 'amber' | 'purple' | 'red' = 'blue') {
const toneClass =
tone === 'amber'
? active
? 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
: tone === 'purple'
? active
? 'border-purple-500/20 bg-purple-500/10 text-purple-600 dark:text-purple-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
: tone === 'red'
? active
? 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300'
: active
? 'border-blue-500/20 bg-blue-500/10 text-blue-600 dark:text-blue-400'
: 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[8px] font-black uppercase tracking-widest', toneClass)}>
{active ? activeLabel : inactiveLabel}
</span>
);
}
export default function AdminShares() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [shares, setShares] = useState<AdminShare[]>([]);
const [total, setTotal] = useState(0);
async function loadShares(nextFilters = filters) {
setError('');
try {
const result = await getAdminShares(0, 100, nextFilters);
setShares(result.items);
setTotal(result.total);
} catch (err) {
setError(err instanceof Error ? err.message : '加载分享治理列表失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadShares();
}, []);
async function copyText(value: string, successMessage: string) {
try {
await navigator.clipboard.writeText(value);
window.alert(successMessage);
} catch {
setError('复制失败,请手动复制。');
}
}
const activeFilterLabels = [
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
filters.fileName.trim() ? `文件: ${filters.fileName.trim()}` : '',
filters.token.trim() ? `Token: ${filters.token.trim()}` : '',
filters.passwordProtected ? `密码保护: ${filters.passwordProtected === 'true' ? '是' : '否'}` : '',
filters.expired ? `已过期: ${filters.expired === 'true' ? '是' : '否'}` : '',
].filter(Boolean);
const columns: ColumnDef<AdminShare>[] = [
{
accessorKey: 'token',
header: '分享',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div>
<div className="text-[12px] font-black tracking-tight uppercase">{row.original.shareName || row.original.fileName}</div>
<div className="mt-1 break-all font-mono text-[9px] font-black tracking-[0.18em] opacity-30">{row.original.token}</div>
<div className="mt-3 flex flex-wrap gap-2">
{boolBadge(row.original.passwordProtected, '需密码', '无密码', 'amber')}
{boolBadge(row.original.expired, '已过期', '未过期', 'red')}
</div>
</div>
),
},
{
id: 'permissions',
header: '权限',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div>
<div className="flex flex-wrap gap-2">
{boolBadge(row.original.allowDownload, '可下载', '仅查看', 'blue')}
{boolBadge(row.original.allowImport, '可导入', '受保护', 'purple')}
</div>
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
Max DL {row.original.maxDownloads ?? '∞'}
</div>
</div>
),
},
{
id: 'owner',
header: '所属用户',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div>
<div className="text-[11px] font-black uppercase tracking-tight text-blue-500">{row.original.ownerUsername}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-30">{row.original.ownerEmail}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">UID #{row.original.ownerId}</div>
</div>
),
},
{
id: 'fileInfo',
header: '文件信息',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div>
<div className="text-[11px] font-black uppercase tracking-tight">{row.original.fileName}</div>
<div className="mt-1 truncate max-w-[260px] text-[9px] font-black uppercase tracking-[0.18em] opacity-30">
{row.original.filePath}
</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
{row.original.directory ? '目录' : `${formatBytes(row.original.fileSize)} / ${row.original.fileContentType || '-'}`}
</div>
</div>
),
},
{
id: 'stats',
header: '统计',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div className="space-y-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">
<div> {row.original.downloadCount}</div>
<div> {row.original.viewCount}</div>
</div>
),
},
{
id: 'time',
header: '时间',
meta: {
thClassName: 'px-6 py-5 text-left',
tdClassName: 'px-6 py-5 align-top',
},
cell: ({ row }) => (
<div>
<div className="text-[10px] font-bold uppercase tracking-tighter opacity-30">{formatDateTime(row.original.createdAt)}</div>
<div className="mt-1 text-[9px] font-black uppercase tracking-[0.18em] opacity-25">
{row.original.expiresAt ? formatDateTime(row.original.expiresAt) : '永久有效'}
</div>
</div>
),
},
{
id: 'actions',
header: '操作',
meta: {
thClassName: 'px-6 py-5 text-right',
tdClassName: 'px-6 py-5 align-top text-right',
},
cell: ({ row }) => {
const share = row.original;
return (
<div className="flex justify-end gap-2 opacity-30 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={() => void copyText(share.token, '分享 Token 已复制')}
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 transition-all hover:bg-blue-600 hover:text-white"
title="复制 Token"
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => window.open(`${window.location.origin}/share/${share.token}`, '_blank', 'noopener,noreferrer')}
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 transition-all hover:bg-blue-600 hover:text-white"
title="打开分享"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
type="button"
onClick={async () => {
if (!window.confirm(`确认删除分享 ${share.shareName || share.fileName} 吗?`)) {
return;
}
await deleteAdminShare(share.id);
setLoading(true);
await loadShares();
}}
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-red-500 transition-all hover:bg-red-500 hover:text-white"
title="删除分享"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
},
},
];
const table = useReactTable({
data: shares,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.id),
});
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex items-center justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"> / Token / </p>
</div>
<button
type="button"
onClick={() => {
setLoading(true);
void loadShares();
}}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 font-black text-[11px] uppercase tracking-widest transition-all hover:bg-white/40"
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
</button>
</div>
<form
onSubmit={(event) => {
event.preventDefault();
setLoading(true);
void loadShares(filters);
}}
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
>
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25"> `GET /api/admin/shares` </p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1fr_1fr_1.2fr_0.8fr_0.8fr]">
<label className="relative block group">
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
<input
value={filters.userQuery}
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
placeholder="所有者"
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.fileName}
onChange={(event) => setFilters((current) => ({ ...current, fileName: event.target.value }))}
placeholder="文件名"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.token}
onChange={(event) => setFilters((current) => ({ ...current, token: event.target.value }))}
placeholder="分享 Token"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<select
value={filters.passwordProtected}
onChange={(event) => setFilters((current) => ({ ...current, passwordProtected: event.target.value as 'true' | 'false' | '' }))}
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</label>
<label className="relative block group">
<select
value={filters.expired}
onChange={(event) => setFilters((current) => ({ ...current, expired: event.target.value as 'true' | 'false' | '' }))}
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
</label>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{activeFilterLabels.length ? (
activeFilterLabels.map((label) => (
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
{label}
</span>
))
) : (
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25"></span>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
setFilters(DEFAULT_FILTERS);
setLoading(true);
void loadShares(DEFAULT_FILTERS);
}}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
>
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
>
</button>
</div>
</div>
</form>
{error ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">
{error}
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
<span> {total} </span>
<span> {shares.length} </span>
</div>
<div className="flex-1 min-h-0">
{loading && shares.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-[1500px] divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta;
return (
<th
key={header.id}
colSpan={header.colSpan}
className={cn(
'text-[9px] font-black uppercase tracking-[0.2em] opacity-40',
meta?.thClassName
)}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
);
})}
</tr>
))}
</thead>
<motion.tbody variants={container} initial="hidden" animate="show" className="divide-y divide-white/10 dark:divide-white/5">
{table.getRowModel().rows.map((row) => (
<motion.tr key={row.id} variants={itemVariants} className="group transition-colors hover:bg-white/10 dark:hover:bg-white/5">
{row.getVisibleCells().map((cell) => {
const meta = cell.column.columnDef.meta;
return (
<td key={cell.id} className={cn(meta?.tdClassName)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</motion.tr>
))}
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={table.getAllColumns().length} className="px-8 py-20 text-center text-[10px] font-black uppercase tracking-widest opacity-30">
</td>
</tr>
) : null}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -1,6 +1,13 @@
import { useEffect, useState } from 'react';
import { ArrowRightLeft, Edit2, Play, Plus, RefreshCw, Square } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type RowData,
} from '@tanstack/react-table';
import {
createStorageMigration,
createStoragePolicy,
@@ -14,6 +21,13 @@ import {
import { formatBytes } from '@/src/lib/format';
import { cn } from '@/src/lib/utils';
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
thClassName?: string;
tdClassName?: string;
}
}
function createDefaultCapabilities(maxObjectSize = 1024 * 1024 * 1024): StoragePolicyCapabilities {
return {
directUpload: false,
@@ -67,6 +81,161 @@ export default function AdminStoragePoliciesList() {
const [editingPolicy, setEditingPolicy] = useState<AdminStoragePolicy | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<StoragePolicyUpsertPayload>(buildInitialForm());
const [migratingPolicy, setMigratingPolicy] = useState<AdminStoragePolicy | null>(null);
const [migrationTargetPolicyId, setMigrationTargetPolicyId] = useState('');
const [migrationSubmitting, setMigrationSubmitting] = useState(false);
const [migrationNotice, setMigrationNotice] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const columns: ColumnDef<AdminStoragePolicy>[] = [
{
accessorKey: 'name',
header: '名称',
meta: {
thClassName: 'px-8 py-5 text-left',
tdClassName: 'px-8 py-5',
},
cell: ({ row }) => {
const policy = row.original;
return (
<div>
<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>
</div>
);
},
},
{
accessorKey: 'type',
header: '后端类型',
meta: {
thClassName: 'px-8 py-5 text-left',
tdClassName: 'px-8 py-5',
},
cell: ({ getValue }) => (
<span className="font-black text-[10px] uppercase tracking-widest opacity-60 bg-white/10 px-2 py-0.5 rounded-sm">
{String(getValue())}
</span>
),
},
{
id: 'endpoint',
header: '访问端点',
meta: {
thClassName: 'px-8 py-5 text-left',
tdClassName: 'px-8 py-5',
},
cell: ({ row }) => {
const policy = row.original;
return (
<div>
<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>
</div>
);
},
},
{
id: 'status',
header: '状态',
meta: {
thClassName: 'px-8 py-5 text-left',
tdClassName: 'px-8 py-5',
},
cell: ({ row }) => {
const policy = row.original;
return (
<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')} />
{policy.enabled ? '启用' : '停用'}
</span>
);
},
},
{
accessorKey: 'maxSizeBytes',
header: '对象上限',
meta: {
thClassName: 'px-8 py-5 text-left',
tdClassName: 'px-8 py-5 font-black opacity-60 text-xs tracking-tighter',
},
cell: ({ getValue }) => formatBytes(Number(getValue())),
},
{
id: 'actions',
header: '操作',
meta: {
thClassName: 'px-8 py-5 text-right',
tdClassName: 'px-8 py-5 text-right',
},
cell: ({ row }) => {
const policy = row.original;
return (
<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={() => openMigrationDialog(policy)}
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>
);
},
},
];
const table = useReactTable({
data: policies,
columns,
getCoreRowModel: getCoreRowModel(),
});
async function loadPolicies() {
setError('');
@@ -99,6 +268,61 @@ export default function AdminStoragePoliciesList() {
}
}
function openMigrationDialog(policy: AdminStoragePolicy) {
const firstTargetPolicy = policies.find((item) => item.id !== policy.id);
setMigratingPolicy(policy);
setMigrationTargetPolicyId(firstTargetPolicy ? String(firstTargetPolicy.id) : '');
setMigrationNotice(null);
}
function closeMigrationDialog() {
setMigratingPolicy(null);
setMigrationTargetPolicyId('');
setMigrationSubmitting(false);
}
async function submitMigration() {
if (!migratingPolicy) {
return;
}
const targetPolicyId = Number(migrationTargetPolicyId);
if (!Number.isInteger(targetPolicyId) || targetPolicyId <= 0) {
setMigrationNotice({
type: 'error',
message: '请输入有效的目标策略 ID',
});
return;
}
if (targetPolicyId === migratingPolicy.id) {
setMigrationNotice({
type: 'error',
message: '目标策略不能与源策略相同',
});
return;
}
setMigrationSubmitting(true);
setMigrationNotice(null);
try {
await createStorageMigration(migratingPolicy.id, targetPolicyId);
setMigrationNotice({
type: 'success',
message: `已创建从 PID::${migratingPolicy.id} 到 PID::${targetPolicyId} 的迁移任务`,
});
closeMigrationDialog();
await loadPolicies();
} catch (err) {
setMigrationNotice({
type: 'error',
message: err instanceof Error ? err.message : '创建迁移任务失败',
});
setMigrationSubmitting(false);
}
}
return (
<motion.div
initial={{ opacity: 0 }}
@@ -138,6 +362,19 @@ export default function AdminStoragePoliciesList() {
</div>
</div>
{migrationNotice ? (
<div
className={cn(
'mb-8 rounded-lg border px-6 py-4 text-xs font-bold backdrop-blur-md',
migrationNotice.type === 'success'
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400'
)}
>
{migrationNotice.message}
</div>
) : null}
{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">
@@ -148,93 +385,33 @@ export default function AdminStoragePoliciesList() {
<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>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={cn(header.column.columnDef.meta?.thClassName)}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</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>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-white/10 dark:hover:bg-white/5 transition-colors group">
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
cell.column.columnDef.meta?.thClassName,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
@@ -379,6 +556,82 @@ export default function AdminStoragePoliciesList() {
</motion.div>
</div>
) : null}
<AnimatePresence>
{migratingPolicy ? (
<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.96, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.96, opacity: 0 }}
className="w-full max-w-2xl glass-panel-no-hover rounded-lg p-10 shadow-2xl border-white/20"
>
<h2 className="text-3xl font-black tracking-tighter uppercase"></h2>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></p>
<div className="mt-8 grid gap-4 rounded-lg border border-white/10 bg-white/5 p-5">
<div>
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40"></div>
<div className="mt-2 text-sm font-black tracking-tight">{migratingPolicy.name}</div>
<div className="mt-1 text-[10px] font-bold opacity-40">PID::{migratingPolicy.id}</div>
</div>
<div className="h-px bg-white/10" />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"></label>
<select
value={migrationTargetPolicyId}
onChange={(event) => setMigrationTargetPolicyId(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"
>
<option value=""></option>
{policies
.filter((item) => item.id !== migratingPolicy.id)
.map((policy) => (
<option key={policy.id} value={policy.id}>
{policy.name} / PID::{policy.id} / {policy.type}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 ml-1"> ID</label>
<input
type="number"
min="1"
value={migrationTargetPolicyId}
onChange={(event) => setMigrationTargetPolicyId(event.target.value)}
placeholder="例如 12"
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>
<p className="text-[10px] font-bold leading-5 opacity-50">
ID
</p>
</div>
<div className="mt-10 flex justify-end gap-3">
<button
type="button"
onClick={closeMigrationDialog}
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 submitMigration()}
disabled={migrationSubmitting}
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] disabled:cursor-not-allowed disabled:opacity-60 transition-all"
>
{migrationSubmitting ? '创建中...' : '创建迁移任务'}
</button>
</div>
</motion.div>
</div>
) : null}
</AnimatePresence>
</motion.div>
);
}

View File

@@ -1 +1,743 @@
export default function AdminTasks() { return <div className='p-8'><h1 className='text-2xl font-black'>Admin Tasks ()</h1></div>; }
import { useEffect, useRef, useState, type ReactNode } from 'react';
import {
AlertTriangle,
ChevronLeft,
ChevronRight,
Clock3,
FileCode2,
ListTodo,
PanelRightOpen,
RefreshCw,
Search,
User,
} from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/src/lib/utils';
import { formatDateTime } from '@/src/lib/format';
import { getAdminTask, getAdminTasks, type AdminTask, type AdminTaskQuery } from '@/src/lib/admin-tasks';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
},
},
};
const itemVariants = {
hidden: { y: 12, opacity: 0 },
show: { y: 0, opacity: 1 },
};
const DEFAULT_FILTERS: AdminTaskQuery = {
userQuery: '',
type: '',
status: '',
failureCategory: '',
leaseState: '',
};
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function taskTypeLabel(type: string) {
const labels: Record<string, string> = {
ARCHIVE: '归档',
EXTRACT: '解压',
MEDIA_META: '媒体元数据',
STORAGE_POLICY_MIGRATION: '存储迁移',
};
return labels[type] ?? type;
}
function taskStatusLabel(status: string) {
const labels: Record<string, string> = {
QUEUED: '排队中',
RUNNING: '执行中',
COMPLETED: '已完成',
FAILED: '已失败',
CANCELLED: '已取消',
};
return labels[status] ?? status;
}
function failureCategoryLabel(category: string | null) {
if (!category) {
return '-';
}
const labels: Record<string, string> = {
UNSUPPORTED_INPUT: '不支持输入',
DATA_STATE: '数据状态异常',
TRANSIENT_INFRASTRUCTURE: '临时基础设施',
RATE_LIMITED: '触发限流',
UNKNOWN: '未知',
};
return labels[category] ?? category;
}
function leaseStateLabel(leaseState: string) {
const labels: Record<string, string> = {
ACTIVE: '活跃',
LEASED: '已租约',
EXPIRED: '已过期',
FREE: '空闲',
NONE: '空闲',
};
return labels[leaseState] ?? leaseState;
}
function statusTone(status: string) {
switch (status) {
case 'RUNNING':
return 'blue';
case 'COMPLETED':
return 'green';
case 'FAILED':
return 'red';
case 'CANCELLED':
return 'amber';
case 'QUEUED':
default:
return 'gray';
}
}
function failureTone(category: string | null) {
switch (category) {
case 'TRANSIENT_INFRASTRUCTURE':
return 'blue';
case 'RATE_LIMITED':
return 'amber';
case 'UNSUPPORTED_INPUT':
case 'DATA_STATE':
case 'UNKNOWN':
return 'red';
default:
return 'gray';
}
}
function leaseTone(leaseState: string) {
switch (leaseState) {
case 'ACTIVE':
case 'LEASED':
return 'blue';
case 'EXPIRED':
return 'red';
default:
return 'gray';
}
}
function pillClass(tone: 'blue' | 'green' | 'amber' | 'red' | 'gray') {
switch (tone) {
case 'green':
return 'border-green-500/20 bg-green-500/10 text-green-600 dark:text-green-400';
case 'blue':
return 'border-blue-500/20 bg-blue-500/10 text-blue-600 dark:text-blue-400';
case 'amber':
return 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400';
case 'red':
return 'border-red-500/20 bg-red-500/10 text-red-600 dark:text-red-400';
case 'gray':
default:
return 'border-white/10 bg-white/5 text-gray-500 dark:text-gray-300';
}
}
function Badge({
children,
tone = 'gray',
}: {
children: ReactNode;
tone?: 'blue' | 'green' | 'amber' | 'red' | 'gray';
}) {
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[9px] font-black uppercase tracking-[0.18em]', pillClass(tone))}>
{children}
</span>
);
}
function SectionTitle({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div className="mb-6">
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30">{title}</h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25">{subtitle}</p>
</div>
);
}
function MetricCard({
icon,
label,
value,
tone,
}: {
icon: ReactNode;
label: string;
value: string;
tone: 'blue' | 'green' | 'amber' | 'red' | 'gray';
}) {
return (
<div className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-2xl transition-all hover:border-white/20">
<div className={cn('mb-5 flex h-12 w-12 items-center justify-center rounded-lg border shadow-[0_0_15px_rgba(59,130,246,0.08)]', pillClass(tone))}>
{icon}
</div>
<h3 className="text-3xl font-black tracking-tight">{value}</h3>
<p className="mt-2 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">{label}</p>
</div>
);
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string;
value: ReactNode;
valueClassName?: string;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b border-white/10 py-3 last:border-0 last:pb-0">
<span className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30">{label}</span>
<span className={cn('max-w-[66%] text-right text-[11px] font-bold leading-5 break-all', valueClassName)}>{value}</span>
</div>
);
}
function parseJson(value: string | null) {
if (!value) {
return null;
}
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
}
function formatJsonPreview(value: string | null) {
const parsed = parseJson(value);
if (parsed == null) {
return '-';
}
if (typeof parsed === 'string') {
return parsed;
}
return JSON.stringify(parsed, null, 2);
}
function isActiveTask(status: string) {
return status === 'QUEUED' || status === 'RUNNING';
}
export default function AdminTasks() {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [listError, setListError] = useState('');
const [detailError, setDetailError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [pageSize, setPageSize] = useState(20);
const [pageData, setPageData] = useState<{
items: AdminTask[];
total: number;
page: number;
size: number;
} | null>(null);
const [selectedTask, setSelectedTask] = useState<AdminTask | null>(null);
const requestSeqRef = useRef(0);
async function loadTasks(nextPage = 0, nextFilters = filters, nextPageSize = pageSize, isRefresh = false) {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setListError('');
try {
const result = await getAdminTasks(nextPage, nextPageSize, nextFilters);
setPageData(result);
} catch (err) {
setListError(err instanceof Error ? err.message : '加载任务监控列表失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
void loadTasks();
}, []);
async function openTaskDetail(task: AdminTask) {
setSelectedTask(task);
setDetailLoading(true);
setDetailError('');
const seq = ++requestSeqRef.current;
try {
const detail = await getAdminTask(task.id);
if (seq !== requestSeqRef.current) {
return;
}
setSelectedTask(detail);
} catch (err) {
if (seq !== requestSeqRef.current) {
return;
}
setDetailError(err instanceof Error ? err.message : '加载任务详情失败');
} finally {
if (seq === requestSeqRef.current) {
setDetailLoading(false);
}
}
}
function handleResetFilters() {
setFilters(DEFAULT_FILTERS);
setSelectedTask(null);
void loadTasks(0, DEFAULT_FILTERS);
}
const items = pageData?.items ?? [];
const total = pageData?.total ?? 0;
const currentPage = pageData?.page ?? 0;
const currentSize = pageData?.size ?? pageSize;
const pageCount = pageData ? Math.max(1, Math.ceil((pageData.total || 0) / pageData.size)) : 0;
const activeCount = items.filter((item) => isActiveTask(item.status)).length;
const failedCount = items.filter((item) => item.status === 'FAILED').length;
const retryScheduledCount = items.filter((item) => item.retryScheduled).length;
const activeFilterLabels = [
filters.userQuery.trim() ? `用户: ${filters.userQuery.trim()}` : '',
filters.type.trim() ? `类型: ${filters.type.trim()}` : '',
filters.status.trim() ? `状态: ${filters.status.trim()}` : '',
filters.failureCategory.trim() ? `失败分类: ${filters.failureCategory.trim()}` : '',
filters.leaseState.trim() ? `租约状态: ${filters.leaseState.trim()}` : '',
].filter(Boolean);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex h-full flex-col overflow-y-auto p-8 text-gray-900 dark:text-gray-100"
>
<div className="mb-10 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="animate-text-reveal text-4xl font-black tracking-tight text-gray-900 dark:text-white"></h1>
<p className="mt-3 text-[10px] font-black uppercase tracking-[0.2em] opacity-40">
`GET /api/admin/tasks` / `GET /api/admin/tasks/:id` /
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<label className="rounded-lg glass-panel px-4 py-3 text-[11px] font-black uppercase tracking-widest">
<span className="mr-3 opacity-40"></span>
<select
value={pageSize}
onChange={(event) => {
const nextSize = Number(event.target.value);
setPageSize(nextSize);
void loadTasks(0, filters, nextSize);
}}
className="bg-transparent outline-none"
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => {
void loadTasks(currentPage, filters, currentSize, true);
}}
disabled={loading || refreshing}
className="flex items-center gap-3 rounded-lg glass-panel px-6 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/40 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className={cn('h-4 w-4', refreshing && 'animate-spin')} />
</button>
</div>
</div>
<motion.section variants={container} initial="hidden" animate="show" className="mb-10 grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-5">
<motion.div variants={itemVariants}>
<MetricCard icon={<ListTodo className="h-6 w-6" />} label="任务总数" value={String(total)} tone="blue" />
</motion.div>
<motion.div variants={itemVariants}>
<MetricCard icon={<Clock3 className="h-6 w-6" />} label="当前页数量" value={String(items.length)} tone="gray" />
</motion.div>
<motion.div variants={itemVariants}>
<MetricCard icon={<RefreshCw className="h-6 w-6" />} label="当前页进行中" value={String(activeCount)} tone="green" />
</motion.div>
<motion.div variants={itemVariants}>
<MetricCard icon={<AlertTriangle className="h-6 w-6" />} label="当前页失败" value={String(failedCount)} tone="red" />
</motion.div>
<motion.div variants={itemVariants}>
<MetricCard icon={<PanelRightOpen className="h-6 w-6" />} label="已安排重试" value={String(retryScheduledCount)} tone="amber" />
</motion.div>
</motion.section>
<form
onSubmit={(event) => {
event.preventDefault();
void loadTasks(0, filters, pageSize);
}}
className="mb-8 glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl"
>
<SectionTitle title="筛选器" subtitle="只使用后端支持的任务查询参数,避免前端做额外猜测" />
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[1.1fr_0.9fr_0.8fr_1fr_0.9fr]">
<label className="relative block group">
<Search className="pointer-events-none absolute left-5 top-1/2 h-4 w-4 -translate-y-1/2 opacity-30 transition-colors group-focus-within:text-blue-500" />
<input
value={filters.userQuery ?? ''}
onChange={(event) => setFilters((current) => ({ ...current, userQuery: event.target.value }))}
placeholder="所有者"
className="w-full rounded-lg border border-white/10 bg-white/10 py-4 pl-14 pr-5 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.type ?? ''}
onChange={(event) => setFilters((current) => ({ ...current, type: event.target.value }))}
placeholder="任务类型"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.status ?? ''}
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}
placeholder="状态"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.failureCategory ?? ''}
onChange={(event) => setFilters((current) => ({ ...current, failureCategory: event.target.value }))}
placeholder="失败分类"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
<label className="relative block group">
<input
value={filters.leaseState ?? ''}
onChange={(event) => setFilters((current) => ({ ...current, leaseState: event.target.value }))}
placeholder="租约状态"
className="w-full rounded-lg border border-white/10 bg-white/10 px-5 py-4 outline-none transition-all font-black text-[11px] uppercase tracking-widest placeholder:opacity-20 focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
</label>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{activeFilterLabels.length ? (
activeFilterLabels.map((label) => (
<span key={label} className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-[9px] font-black uppercase tracking-[0.2em] opacity-70">
{label}
</span>
))
) : (
<span className="text-[9px] font-black uppercase tracking-[0.22em] opacity-25"></span>
)}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleResetFilters}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-3 text-[11px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
>
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-[11px] font-black uppercase tracking-widest text-white transition-all hover:bg-blue-500"
>
</button>
</div>
</div>
</form>
{listError ? (
<div className="mb-8 rounded-lg border border-red-500/20 bg-red-500/10 px-6 py-4 text-xs font-bold uppercase tracking-widest text-red-600 dark:text-red-400">
{listError}
</div>
) : null}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
<span>
{total}
{pageData ? ` / 第 ${currentPage + 1} 页,共 ${pageCount}` : ''}
</span>
<span> {items.length} </span>
</div>
<div className="grid flex-1 min-h-0 grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="min-h-0">
{loading && !pageData ? (
<div className="glass-panel-no-hover rounded-lg px-4 py-16 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
...
</div>
) : items.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-[1500px] divide-y divide-white/10">
<thead className="bg-white/10 dark:bg-black/40">
<tr>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"> / </th>
<th className="px-6 py-5 text-left text-[9px] font-black uppercase tracking-[0.2em] opacity-40"></th>
<th className="px-6 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">
{items.map((task) => {
const isSelected = selectedTask?.id === task.id;
return (
<motion.tr
key={task.id}
variants={itemVariants}
onClick={() => {
void openTaskDetail(task);
}}
className={cn(
'group cursor-pointer transition-colors hover:bg-white/10 dark:hover:bg-white/5',
isSelected && 'bg-blue-500/10 dark:bg-blue-500/10',
)}
>
<td className="px-6 py-5 align-top">
<div className="text-[12px] font-black tracking-tight uppercase">{taskTypeLabel(task.type)}</div>
<div className="mt-1 font-mono text-[9px] font-black tracking-[0.18em] opacity-30">#{task.id}</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge tone="gray">{task.type}</Badge>
{task.correlationId ? <Badge tone="blue">{task.correlationId}</Badge> : null}
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black tracking-tight uppercase">{task.ownerUsername || '-'}</div>
<div className="mt-1 text-[9px] font-mono font-black tracking-[0.16em] opacity-30">{task.ownerEmail || '-'}</div>
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-40"> ID #{task.userId}</div>
</td>
<td className="px-6 py-5 align-top">
<div className="flex flex-wrap gap-2">
<Badge tone={statusTone(task.status)}>{taskStatusLabel(task.status)}</Badge>
{task.retryScheduled ? <Badge tone="amber"></Badge> : <Badge tone="gray"></Badge>}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge tone={failureTone(task.failureCategory)}>{failureCategoryLabel(task.failureCategory)}</Badge>
</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[11px] font-black uppercase tracking-tight">
{task.attemptCount}/{task.maxAttempts}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge tone={leaseTone(task.leaseState)}>{leaseStateLabel(task.leaseState)}</Badge>
<Badge tone="gray">{task.workerOwner || '无 worker'}</Badge>
</div>
<div className="mt-3 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">{formatDateTime(task.nextRunAt)}</div>
</td>
<td className="px-6 py-5 align-top">
<div className="text-[9px] font-black uppercase tracking-[0.18em] opacity-40">{formatDateTime(task.createdAt)}</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">{formatDateTime(task.updatedAt)}</div>
<div className="mt-2 text-[9px] font-black uppercase tracking-[0.18em] opacity-40">{formatDateTime(task.finishedAt)}</div>
</td>
<td className="px-6 py-5 align-top text-right">
<div className="flex justify-end gap-2 opacity-60 transition-opacity group-hover:opacity-100">
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void openTaskDetail(task);
}}
className="rounded-lg border border-white/10 bg-white/5 p-2.5 text-blue-500 shadow-sm transition-all hover:bg-blue-600 hover:text-white"
title="查看详情"
>
<PanelRightOpen className="h-4 w-4" />
</button>
</div>
</td>
</motion.tr>
);
})}
</motion.tbody>
</table>
</div>
</div>
)}
</div>
<aside className="xl:sticky xl:top-6">
<div className="glass-panel-no-hover rounded-2xl border border-white/10 p-6 shadow-3xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-[10px] font-black uppercase tracking-[0.3em] opacity-30"></h2>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.22em] opacity-25"></p>
</div>
{selectedTask ? (
<button
type="button"
onClick={() => {
setSelectedTask(null);
setDetailError('');
setDetailLoading(false);
requestSeqRef.current += 1;
}}
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10"
>
</button>
) : null}
</div>
{detailError ? (
<div className="mb-5 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-[11px] font-bold uppercase tracking-wide text-red-600 dark:text-red-400">
{detailError}
</div>
) : null}
{selectedTask ? (
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={statusTone(selectedTask.status)}>{taskStatusLabel(selectedTask.status)}</Badge>
<Badge tone="gray">{taskTypeLabel(selectedTask.type)}</Badge>
<Badge tone={selectedTask.retryScheduled ? 'amber' : 'gray'}>{selectedTask.retryScheduled ? '已安排重试' : '未安排重试'}</Badge>
</div>
<div className="mt-4 text-2xl font-black tracking-tight"> #{selectedTask.id}</div>
<div className="mt-2 font-mono text-[9px] font-black tracking-[0.18em] opacity-30">{selectedTask.correlationId || '无 correlationId'}</div>
</div>
{detailLoading ? (
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-8 text-center text-[10px] font-black uppercase tracking-widest opacity-40">
...
</div>
) : null}
<div>
<SectionTitle title="基本信息" subtitle="任务归属、类型、状态与失败分类" />
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<DetailRow label="任务 ID" value={`#${selectedTask.id}`} />
<DetailRow label="类型" value={taskTypeLabel(selectedTask.type)} />
<DetailRow label="状态" value={<Badge tone={statusTone(selectedTask.status)}>{taskStatusLabel(selectedTask.status)}</Badge>} />
<DetailRow label="失败分类" value={<Badge tone={failureTone(selectedTask.failureCategory)}>{failureCategoryLabel(selectedTask.failureCategory)}</Badge>} />
<DetailRow label="重试已安排" value={<Badge tone={selectedTask.retryScheduled ? 'amber' : 'gray'}>{selectedTask.retryScheduled ? '是' : '否'}</Badge>} />
<DetailRow label="所属用户" value={`${selectedTask.ownerUsername || '-'} / ${selectedTask.ownerEmail || '-'}`} />
<DetailRow label="用户 ID" value={`#${selectedTask.userId}`} />
</div>
</div>
<div>
<SectionTitle title="租约信息" subtitle="worker 与 lease 边界,便于排查多实例抢占" />
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<DetailRow label="租约状态" value={<Badge tone={leaseTone(selectedTask.leaseState)}>{leaseStateLabel(selectedTask.leaseState)}</Badge>} />
<DetailRow label="leaseOwner" value={selectedTask.leaseOwner || '-'} />
<DetailRow label="workerOwner" value={selectedTask.workerOwner || '-'} />
<DetailRow label="leaseExpiresAt" value={formatDateTime(selectedTask.leaseExpiresAt)} />
<DetailRow label="heartbeatAt" value={formatDateTime(selectedTask.heartbeatAt)} />
<DetailRow label="nextRunAt" value={formatDateTime(selectedTask.nextRunAt)} />
<DetailRow label="attemptCount" value={`${selectedTask.attemptCount}/${selectedTask.maxAttempts}`} />
<DetailRow label="correlationId" value={selectedTask.correlationId || '-'} />
</div>
</div>
<div>
<SectionTitle title="时间信息" subtitle="创建、更新与完成时间" />
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<DetailRow label="createdAt" value={formatDateTime(selectedTask.createdAt)} />
<DetailRow label="updatedAt" value={formatDateTime(selectedTask.updatedAt)} />
<DetailRow label="finishedAt" value={formatDateTime(selectedTask.finishedAt)} />
</div>
</div>
<div>
<SectionTitle title="公开状态" subtitle="publicStateJson / errorMessage / 原始调度状态" />
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
<div className="mb-4">
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">publicStateJson</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-xl border border-white/10 bg-white/5 p-4 text-[11px] leading-6 text-gray-100">
{formatJsonPreview(selectedTask.publicStateJson)}
</pre>
</div>
<div>
<div className="mb-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-30">errorMessage</div>
<div className={cn('rounded-xl border p-4 text-[11px] leading-6', selectedTask.errorMessage ? 'border-red-500/20 bg-red-500/10 text-red-200' : 'border-white/10 bg-white/5 opacity-70')}>
{selectedTask.errorMessage || '无错误信息'}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border border-dashed border-white/15 bg-white/5 px-4 py-12 text-center">
<FileCode2 className="mx-auto h-10 w-10 opacity-25" />
<p className="mt-4 text-[11px] font-black uppercase tracking-[0.22em] opacity-40"></p>
<p className="mt-2 text-[9px] font-black uppercase tracking-[0.2em] opacity-25"></p>
</div>
)}
</div>
</aside>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<div className="text-[9px] font-black uppercase tracking-[0.22em] opacity-30">
{pageData ? `${currentPage + 1} 页 / 每页 ${currentSize}` : '尚未加载分页信息'}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
if (currentPage <= 0) {
return;
}
void loadTasks(currentPage - 1, filters, currentSize);
}}
disabled={!pageData || currentPage <= 0 || loading}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
if (!pageData || currentPage + 1 >= pageCount) {
return;
}
void loadTasks(currentPage + 1, filters, currentSize);
}}
disabled={!pageData || currentPage + 1 >= pageCount || loading}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-[10px] font-black uppercase tracking-widest transition-all hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</motion.div>
);
}

View File

@@ -1,6 +1,14 @@
import { useEffect, useState } from 'react';
import { Ban, KeyRound, RefreshCw, Search, Shield, Upload, Mail, Phone, ChevronRight } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Ban, Check, Clipboard, KeyRound, PencilLine, RefreshCw, Search, Shield, Mail, Phone, X } from 'lucide-react';
import { motion } from 'motion/react';
import { useForm } from 'react-hook-form';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table';
import { cn } from '@/src/lib/utils';
import {
getAdminUsers,
@@ -29,11 +37,246 @@ const itemVariants = {
show: { y: 0, opacity: 1 }
};
const columnHelper = createColumnHelper<AdminUser>();
type UserEditorFormValues = {
role: AdminUser['role'];
storageQuotaBytes: string;
maxUploadSizeBytes: string;
manualPassword: string;
};
const EMPTY_EDITOR_FORM_VALUES: UserEditorFormValues = {
role: 'USER',
storageQuotaBytes: '',
maxUploadSizeBytes: '',
manualPassword: '',
};
function validateNonNegativeBytes(rawValue: string, label: string) {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return `${label}不能为空`;
}
const value = Number(trimmedValue);
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
return `${label}必须是非负整数`;
}
return true;
}
function parseNonNegativeBytes(rawValue: string, label: string) {
const validation = validateNonNegativeBytes(rawValue, label);
if (validation !== true) {
throw new Error(validation);
}
return Number(rawValue.trim());
}
export default function AdminUsersList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [users, setUsers] = useState<AdminUser[]>([]);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [temporaryPasswords, setTemporaryPasswords] = useState<Record<number, string>>({});
const [copiedTemporaryPasswordUserId, setCopiedTemporaryPasswordUserId] = useState<number | null>(null);
const {
register,
trigger,
getValues,
reset,
resetField,
watch,
formState: { errors },
} = useForm<UserEditorFormValues>({
defaultValues: EMPTY_EDITOR_FORM_VALUES,
mode: 'onSubmit',
reValidateMode: 'onChange',
});
const watchedRole = watch('role');
const columns = useMemo<ColumnDef<AdminUser, unknown>[]>(() => [
columnHelper.display({
id: 'userInfo',
header: '用户信息',
cell: ({ row }) => {
const user = row.original;
return (
<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>
);
},
}),
columnHelper.accessor('role', {
header: '角色',
cell: ({ row, getValue }) => {
const user = row.original;
return (
<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",
getValue() === '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>
);
},
}),
columnHelper.accessor('banned', {
header: '状态',
cell: ({ getValue }) => (
<span className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
getValue()
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
)}>
{getValue() ? '已禁用' : '正常'}
</span>
),
}),
columnHelper.display({
id: 'resources',
header: '资源配额',
cell: ({ row }) => {
const user = row.original;
return (
<>
<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)]"
/>
</div>
<div className="mt-2 text-[9px] font-bold opacity-30 uppercase tracking-widest">
{formatBytes(user.maxUploadSizeBytes)}
</div>
</>
);
},
}),
columnHelper.accessor('createdAt', {
header: '注册时间',
cell: ({ getValue }) => (
<div className="text-[10px] font-bold opacity-30 tracking-tighter uppercase">
{formatDateTime(getValue())}
</div>
),
}),
columnHelper.display({
id: 'actions',
header: '操作',
cell: ({ row }) => {
const user = row.original;
return (
<>
<div className="flex justify-end gap-2 opacity-30 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => openEditor(user)}
className="p-2.5 rounded-lg glass-panel hover:bg-blue-600 hover:text-white text-blue-500 transition-all border-white/10"
title="打开编辑面板"
>
<PencilLine className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => void generateTemporaryPassword(user.id)}
className="p-2.5 rounded-lg glass-panel hover:bg-violet-500 hover:text-white text-violet-500 transition-all border-white/10"
title="生成临时密码"
>
<RefreshCw 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>
{temporaryPasswords[user.id] ? (
<div className="mt-4 rounded-lg border border-violet-500/20 bg-violet-500/10 px-4 py-3 text-left shadow-inner">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-violet-500">
</div>
<div className="mt-1 text-[10px] font-bold opacity-50">
</div>
</div>
<button
type="button"
onClick={() =>
setTemporaryPasswords((current) => {
const next = { ...current };
delete next[user.id];
return next;
})
}
className="rounded-full border border-white/10 p-1.5 text-violet-500 transition-colors hover:bg-violet-500 hover:text-white"
title="关闭临时密码提示"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<code className="rounded-md border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-black tracking-[0.15em] text-white">
{temporaryPasswords[user.id]}
</code>
<button
type="button"
onClick={() => void copyTemporaryPassword(user.id, temporaryPasswords[user.id])}
className="inline-flex items-center gap-2 rounded-md border border-white/10 px-3 py-2 text-[10px] font-black uppercase tracking-widest text-violet-500 transition-colors hover:bg-violet-500 hover:text-white"
>
{copiedTemporaryPasswordUserId === user.id ? (
<>
<Check className="h-3.5 w-3.5" />
</>
) : (
<>
<Clipboard className="h-3.5 w-3.5" />
</>
)}
</button>
</div>
</div>
) : null}
</>
);
},
}),
], [copiedTemporaryPasswordUserId, copyTemporaryPassword, generateTemporaryPassword, mutate, openEditor, temporaryPasswords]);
const table = useReactTable<AdminUser>({
data: users,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => String(row.id),
});
async function loadUsers(nextQuery = query) {
setError('');
@@ -47,10 +290,106 @@ export default function AdminUsersList() {
}
}
useEffect(() => {
if (!editingUser) {
return;
}
const latestUser = users.find((user) => user.id === editingUser.id);
if (!latestUser) {
return;
}
setEditingUser(latestUser);
reset(
{
role: latestUser.role,
storageQuotaBytes: String(latestUser.storageQuotaBytes),
maxUploadSizeBytes: String(latestUser.maxUploadSizeBytes),
manualPassword: getValues('manualPassword'),
}
);
}, [editingUser, users]);
useEffect(() => {
void loadUsers();
}, []);
function openEditor(user: AdminUser) {
setError('');
setEditingUser(user);
reset({
role: user.role,
storageQuotaBytes: String(user.storageQuotaBytes),
maxUploadSizeBytes: String(user.maxUploadSizeBytes),
manualPassword: '',
});
}
function closeEditor() {
setEditingUser(null);
reset(EMPTY_EDITOR_FORM_VALUES);
}
async function saveEditorProfile() {
if (!editingUser) {
return;
}
try {
const isValid = await trigger(['role', 'storageQuotaBytes', 'maxUploadSizeBytes']);
if (!isValid) {
return;
}
const currentValues = getValues();
const nextStorageQuotaBytes = parseNonNegativeBytes(currentValues.storageQuotaBytes, '存储配额');
const nextMaxUploadSizeBytes = parseNonNegativeBytes(currentValues.maxUploadSizeBytes, '最大上传限制');
await mutate(async () => {
if (currentValues.role !== editingUser.role) {
await updateUserRole(editingUser.id, currentValues.role);
}
if (nextStorageQuotaBytes !== editingUser.storageQuotaBytes) {
await updateUserStorageQuota(editingUser.id, nextStorageQuotaBytes);
}
if (nextMaxUploadSizeBytes !== editingUser.maxUploadSizeBytes) {
await updateUserMaxUploadSize(editingUser.id, nextMaxUploadSizeBytes);
}
});
} catch (err) {
setError(err instanceof Error ? err.message : '保存基础配置失败');
}
}
async function submitManualPassword() {
if (!editingUser) {
return;
}
const isValid = await trigger('manualPassword');
if (!isValid) {
return;
}
const nextPassword = getValues('manualPassword').trim();
await mutate(async () => {
await updateUserPassword(editingUser.id, nextPassword);
resetField('manualPassword');
setTemporaryPasswords((current) => {
const next = { ...current };
delete next[editingUser.id];
return next;
});
});
}
async function generateTemporaryPassword(userId: number) {
await mutate(async () => {
const result = await resetUserPassword(userId);
setTemporaryPasswords((current) => ({
...current,
[userId]: result.temporaryPassword,
}));
setCopiedTemporaryPasswordUserId(null);
});
}
async function mutate(action: () => Promise<unknown>) {
try {
await action();
@@ -60,6 +399,18 @@ export default function AdminUsersList() {
}
}
async function copyTemporaryPassword(userId: number, password: string) {
try {
await navigator.clipboard.writeText(password);
setCopiedTemporaryPasswordUserId(userId);
window.setTimeout(() => {
setCopiedTemporaryPasswordUserId((current) => (current === userId ? null : current));
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : '复制临时密码失败');
}
}
return (
<motion.div
initial={{ opacity: 0 }}
@@ -69,8 +420,8 @@ export default function AdminUsersList() {
>
<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>
<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"
@@ -105,154 +456,272 @@ export default function AdminUsersList() {
{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">
<div className="grid flex-1 min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
{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))}
<>
<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">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
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"
"px-8 py-5 text-[9px] font-black uppercase tracking-[0.2em] opacity-40",
header.column.id === 'actions' ? 'text-right' : 'text-left'
)}
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>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<motion.tbody
variants={container}
initial="hidden"
animate="show"
className="divide-y divide-white/10 dark:divide-white/5"
>
{table.getRowModel().rows.map((row) => {
const user = row.original;
const isEditing = editingUser?.id === user.id;
return (
<motion.tr
key={row.id}
variants={itemVariants}
className={cn(
"group transition-colors",
isEditing ? "bg-blue-500/10 dark:bg-blue-500/5" : "hover:bg-white/10 dark:hover:bg-white/5"
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className={cn(
"px-8 py-5 align-top",
cell.column.id === 'actions' ? 'text-right' : 'text-left'
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</motion.tr>
);
})}
{table.getRowModel().rows.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>
<aside className="glass-panel-no-hover rounded-lg border border-white/10 p-6 shadow-3xl xl:sticky xl:top-6 xl:self-start">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div>
<h2 className="mt-2 text-lg font-black tracking-tight uppercase">
{editingUser ? editingUser.username : '请选择用户'}
</h2>
<p className="mt-2 text-[10px] font-bold opacity-40 leading-relaxed">
{editingUser
? '这里负责角色、存储配额、最大上传限制和手动改密。临时密码生成仍保留在表格快捷操作里,避免和手动改密混在一起。'
: '从左侧表格点击“编辑”打开该用户的策略面板。'}
</p>
</div>
{editingUser ? (
<button
type="button"
onClick={closeEditor}
className="rounded-full border border-white/10 px-3 py-1.5 text-[10px] font-black uppercase tracking-widest opacity-50 transition-colors hover:bg-white/10 hover:opacity-100"
>
</button>
) : null}
</div>
{editingUser ? (
<div className="mt-6 space-y-6">
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div>
<div className="mt-2 text-[12px] font-black uppercase tracking-tight">{editingUser.email}</div>
</div>
<span
className={cn(
"inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border",
editingUser.banned
? "bg-red-500/10 text-red-500 border-red-500/20"
: "bg-green-500/10 text-green-500 border-green-500/20"
)}
>
{editingUser.banned ? '已禁用' : '正常'}
</span>
</div>
<div className="mt-4 text-[10px] font-black uppercase tracking-tight">
{formatBytes(editingUser.usedStorageBytes)} / <span className="opacity-30">{formatBytes(editingUser.storageQuotaBytes)}</span>
</div>
<div className="mt-2 h-1 w-full rounded-full bg-white/10 overflow-hidden">
<div
className="h-full bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]"
style={{ width: `${Math.min(100, (editingUser.usedStorageBytes / editingUser.storageQuotaBytes) * 100)}%` }}
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div>
<div className="mt-1 text-[11px] font-bold opacity-50"></div>
</div>
<span className="inline-flex items-center gap-2 rounded-sm px-2 py-0.5 text-[9px] font-black uppercase tracking-widest border bg-blue-500/10 text-blue-500 border-blue-500/20">
<Shield className="h-3 w-3" />
{watchedRole}
</span>
</div>
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<select
{...register('role', {
validate: (value) => (value === 'USER' || value === 'ADMIN' ? true : '请选择有效角色'),
})}
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black uppercase tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
>
<option value="USER">USER - </option>
<option value="ADMIN">ADMIN - </option>
</select>
{errors.role ? (
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
{errors.role.message}
</p>
) : null}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<input
{...register('storageQuotaBytes', {
validate: (value) => validateNonNegativeBytes(value, '存储配额'),
})}
inputMode="numeric"
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
{errors.storageQuotaBytes ? (
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
{errors.storageQuotaBytes.message}
</p>
) : null}
</label>
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<input
{...register('maxUploadSizeBytes', {
validate: (value) => validateNonNegativeBytes(value, '最大上传限制'),
})}
inputMode="numeric"
className="w-full rounded-lg border border-white/10 bg-white/10 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-blue-500/50 focus:ring-4 focus:ring-blue-500/10"
/>
{errors.maxUploadSizeBytes ? (
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-red-500">
{errors.maxUploadSizeBytes.message}
</p>
) : null}
</label>
</div>
<button
type="button"
onClick={() => void saveEditorProfile()}
className="inline-flex w-full items-center justify-center gap-3 rounded-lg bg-blue-600 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-white transition-colors hover:bg-blue-500"
>
<Check className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[9px] font-black uppercase tracking-[0.2em] text-amber-500"></div>
<div className="mt-1 text-[10px] font-bold opacity-60 leading-relaxed">
</div>
</div>
<KeyRound className="h-4 w-4 text-amber-500" />
</div>
<label className="block">
<span className="mb-2 block text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></span>
<input
{...register('manualPassword', {
validate: (value) => (value.trim() ? true : '请输入要手动设置的新密码'),
})}
type="password"
autoComplete="new-password"
className="w-full rounded-lg border border-white/10 bg-black/20 px-4 py-3 text-[11px] font-black tracking-widest outline-none transition-colors focus:border-amber-500/50 focus:ring-4 focus:ring-amber-500/10"
placeholder="输入后点击“手动设置密码”"
/>
{errors.manualPassword ? (
<p className="mt-2 text-[10px] font-bold uppercase tracking-widest text-amber-500">
{errors.manualPassword.message}
</p>
) : null}
</label>
<button
type="button"
onClick={() => void submitManualPassword()}
className="inline-flex w-full items-center justify-center gap-3 rounded-lg border border-amber-500/20 bg-amber-500/15 px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] text-amber-500 transition-colors hover:bg-amber-500 hover:text-white"
>
<KeyRound className="h-4 w-4" />
</button>
<p className="text-[10px] font-bold opacity-50 leading-relaxed">
使
</p>
</div>
<div className="rounded-lg border border-white/10 bg-white/5 px-4 py-4">
<div className="text-[9px] font-black uppercase tracking-[0.2em] opacity-30"></div>
<div className="mt-2 text-[10px] font-bold opacity-50 leading-relaxed">
/
</div>
<button
type="button"
onClick={() => void mutate(() => updateUserStatus(editingUser.id, !editingUser.banned))}
className={cn(
"mt-4 inline-flex w-full items-center justify-center gap-3 rounded-lg border px-4 py-3 text-[10px] font-black uppercase tracking-[0.2em] transition-colors",
editingUser.banned
? "border-green-500/20 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
: "border-red-500/20 bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
)}
>
<Ban className="h-4 w-4" />
{editingUser.banned ? '恢复账号' : '禁用账号'}
</button>
</div>
</div>
) : (
<div className="mt-10 rounded-lg border border-dashed border-white/10 px-6 py-12 text-center">
<div className="text-[10px] font-black uppercase tracking-[0.2em] opacity-25"></div>
<p className="mt-3 text-[11px] font-bold opacity-40 leading-relaxed">
</p>
</div>
)}
</aside>
</>
)}
</div>
</motion.div>

View File

@@ -0,0 +1,47 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminAuditLog = {
id: number;
actorUserId: number | null;
actorUsername: string | null;
actorAuthorities: string[] | string | null;
actionType: string;
targetType: string;
targetId: string | null;
summary: string;
detailsJson: string | null;
createdAt: string;
};
export type AdminAuditQuery = {
actorQuery?: string;
actionType?: string;
targetType?: string;
targetId?: string;
};
export async function getAdminAudits(page = 0, size = 100, query: AdminAuditQuery = {}) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
if (query.actorQuery?.trim()) {
params.set('actorQuery', query.actorQuery.trim());
}
if (query.actionType?.trim()) {
params.set('actionType', query.actionType.trim());
}
if (query.targetType?.trim()) {
params.set('targetType', query.targetType.trim());
}
if (query.targetId?.trim()) {
params.set('targetId', query.targetId.trim());
}
return fetchApi<PageResponse<AdminAuditLog>>(`/admin/audits?${params.toString()}`);
}

View File

@@ -0,0 +1,58 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminFileBlobEntityType = 'VERSION' | 'THUMBNAIL' | 'LIVE_PHOTO' | 'TRANSCODE' | 'AVATAR';
export type AdminFileBlobResponse = {
entityId: number;
blobId: number;
objectKey: string;
entityType: AdminFileBlobEntityType;
storagePolicyId: number;
size: number;
contentType: string;
referenceCount: number | null;
linkedStoredFileCount: number;
linkedOwnerCount: number;
sampleOwnerUsername: string | null;
sampleOwnerEmail: string | null;
createdByUserId: number | null;
createdByUsername: string | null;
createdAt: string;
blobCreatedAt: string | null;
blobMissing: boolean;
orphanRisk: boolean;
referenceMismatch: boolean;
};
export type AdminFileBlobQuery = {
userQuery?: string;
storagePolicyId?: number | null;
objectKey?: string;
entityType?: AdminFileBlobEntityType | '';
};
export async function getAdminFileBlobs(page = 0, size = 100, query: AdminFileBlobQuery = {}) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
if (query.userQuery?.trim()) {
params.set('userQuery', query.userQuery.trim());
}
if (query.storagePolicyId != null && Number.isInteger(query.storagePolicyId)) {
params.set('storagePolicyId', String(query.storagePolicyId));
}
if (query.objectKey?.trim()) {
params.set('objectKey', query.objectKey.trim());
}
if (query.entityType) {
params.set('entityType', query.entityType);
}
return fetchApi<PageResponse<AdminFileBlobResponse>>(`/admin/file-blobs?${params.toString()}`);
}

View File

@@ -0,0 +1,34 @@
import { fetchApi } from './api';
import type { AdminStoragePolicy } from './admin-storage-policies';
export type AdminFilesystemResponse = {
overview: {
storageProvider: string;
totalFiles: number;
totalBlobs: number;
totalEntities: number;
};
defaultPolicy: AdminStoragePolicy;
upload: {
proxyUpload: boolean;
directSingleUpload: boolean;
directMultipartUpload: boolean;
effectiveMaxFileSizeBytes: number;
};
mediaProcessing: {
metadataExtractionEnabled: boolean;
nativeThumbnailSupport: boolean;
};
cache: {
backend: string;
filesListTtlSeconds: number;
directoryVersionTtlSeconds: number;
};
webdav: {
enabled: boolean;
};
};
export async function getAdminFilesystem() {
return fetchApi<AdminFilesystemResponse>('/admin/filesystem');
}

View File

@@ -0,0 +1,78 @@
import { fetchApi } from './api';
export type AdminSettings = {
site: {
supported: boolean;
writeSupported: boolean;
};
registration: {
inviteCodeRequired: boolean;
currentInviteCode: string;
managementRoles: string[];
writeSupported: boolean;
};
userSession: {
accessExpirationSeconds: number;
refreshExpirationSeconds: number;
tokenBlacklistEnabled: boolean;
tokenBlacklistTtlBufferSeconds: number;
writeSupported: boolean;
};
transfer: {
offlineTransferStorageLimitBytes: number;
writeSupported: boolean;
};
mediaProcessing: {
metadataExtractionEnabled: boolean;
thumbnailGenerationEnabled: boolean;
videoPosterEnabled: boolean;
writeSupported: boolean;
};
queue: {
backend: string;
mediaMetadataFixedDelayMs: number;
mediaMetadataInitialDelayMs: number;
writeSupported: boolean;
};
appearance: {
supported: boolean;
writeSupported: boolean;
};
server: {
storageProvider: string;
redisEnabled: boolean;
writeSupported: boolean;
};
};
export type AdminRegistrationInviteCodeResponse = {
currentInviteCode: string;
};
export type AdminOfflineTransferStorageLimitResponse = {
offlineTransferStorageLimitBytes: number;
};
export async function getAdminSettings() {
return fetchApi<AdminSettings>('/admin/settings');
}
export async function updateAdminRegistrationInviteCode(inviteCode: string) {
return fetchApi<AdminRegistrationInviteCodeResponse>('/admin/settings/registration/invite-code', {
method: 'PATCH',
body: JSON.stringify({ inviteCode }),
});
}
export async function rotateAdminRegistrationInviteCode() {
return fetchApi<AdminRegistrationInviteCodeResponse>('/admin/settings/registration/invite-code/rotate', {
method: 'POST',
});
}
export async function updateAdminOfflineTransferStorageLimit(offlineTransferStorageLimitBytes: number) {
return fetchApi<AdminOfflineTransferStorageLimitResponse>('/admin/settings/offline-transfer-storage-limit', {
method: 'PATCH',
body: JSON.stringify({ offlineTransferStorageLimitBytes }),
});
}

View File

@@ -0,0 +1,69 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminShare = {
id: number;
token: string;
shareName: string | null;
passwordProtected: boolean;
expired: boolean;
createdAt: string;
expiresAt: string | null;
maxDownloads: number | null;
downloadCount: number;
viewCount: number;
allowImport: boolean;
allowDownload: boolean;
ownerId: number;
ownerUsername: string;
ownerEmail: string;
fileId: number;
fileName: string;
filePath: string;
fileContentType: string;
fileSize: number;
directory: boolean;
};
export type AdminShareQuery = {
userQuery?: string;
fileName?: string;
token?: string;
passwordProtected?: 'true' | 'false' | '';
expired?: 'true' | 'false' | '';
};
export async function getAdminShares(page = 0, size = 100, query: AdminShareQuery = {}) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
if (query.userQuery?.trim()) {
params.set('userQuery', query.userQuery.trim());
}
if (query.fileName?.trim()) {
params.set('fileName', query.fileName.trim());
}
if (query.token?.trim()) {
params.set('token', query.token.trim());
}
if (query.passwordProtected) {
params.set('passwordProtected', query.passwordProtected);
}
if (query.expired) {
params.set('expired', query.expired);
}
return fetchApi<PageResponse<AdminShare>>(`/admin/shares?${params.toString()}`);
}
export async function deleteAdminShare(shareId: number) {
return fetchApi<void>(`/admin/shares/${shareId}`, {
method: 'DELETE',
});
}

View File

@@ -0,0 +1,68 @@
import { fetchApi } from './api';
import type { PageResponse } from './files';
export type AdminTask = {
id: number;
type: string;
status: string;
userId: number;
ownerUsername: string;
ownerEmail: string;
publicStateJson: string | null;
correlationId: string | null;
errorMessage: string | null;
attemptCount: number;
maxAttempts: number;
nextRunAt: string | null;
leaseOwner: string | null;
leaseExpiresAt: string | null;
heartbeatAt: string | null;
createdAt: string;
updatedAt: string;
finishedAt: string | null;
failureCategory: string | null;
retryScheduled: boolean;
workerOwner: string | null;
leaseState: string;
};
export type AdminTaskQuery = {
userQuery?: string;
type?: string;
status?: string;
failureCategory?: string;
leaseState?: string;
};
export async function getAdminTasks(page = 0, size = 20, query: AdminTaskQuery = {}) {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
if (query.userQuery?.trim()) {
params.set('userQuery', query.userQuery.trim());
}
if (query.type?.trim()) {
params.set('type', query.type.trim());
}
if (query.status?.trim()) {
params.set('status', query.status.trim());
}
if (query.failureCategory?.trim()) {
params.set('failureCategory', query.failureCategory.trim());
}
if (query.leaseState?.trim()) {
params.set('leaseState', query.leaseState.trim());
}
return fetchApi<PageResponse<AdminTask>>(`/admin/tasks?${params.toString()}`);
}
export async function getAdminTask(taskId: number) {
return fetchApi<AdminTask>(`/admin/tasks/${taskId}`);
}