Implement coordinated frontend and backend updates
This commit is contained in:
51
front/package-lock.json
generated
51
front/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
455
front/src/admin/audits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
front/src/lib/admin-audits.ts
Normal file
47
front/src/lib/admin-audits.ts
Normal 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()}`);
|
||||
}
|
||||
58
front/src/lib/admin-fileblobs.ts
Normal file
58
front/src/lib/admin-fileblobs.ts
Normal 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()}`);
|
||||
}
|
||||
34
front/src/lib/admin-filesystem.ts
Normal file
34
front/src/lib/admin-filesystem.ts
Normal 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');
|
||||
}
|
||||
78
front/src/lib/admin-settings.ts
Normal file
78
front/src/lib/admin-settings.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
69
front/src/lib/admin-shares.ts
Normal file
69
front/src/lib/admin-shares.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
68
front/src/lib/admin-tasks.ts
Normal file
68
front/src/lib/admin-tasks.ts
Normal 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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user