修改后台权限
This commit is contained in:
@@ -18,6 +18,39 @@ import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src
|
||||
|
||||
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN'];
|
||||
|
||||
function formatLimitSize(bytes: number) {
|
||||
if (bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / 1024 ** index;
|
||||
return `${value >= 10 || index === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function parseLimitInput(value: string): number | null {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb)?$/);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const amount = Number.parseFloat(matched[1] ?? '0');
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const unit = matched[2] ?? 'b';
|
||||
const multiplier = unit === 'tb'
|
||||
? 1024 ** 4
|
||||
: unit === 'gb'
|
||||
? 1024 ** 3
|
||||
: unit === 'mb'
|
||||
? 1024 ** 2
|
||||
: unit === 'kb'
|
||||
? 1024
|
||||
: 1;
|
||||
return Math.floor(amount * multiplier);
|
||||
}
|
||||
|
||||
function UsersListActions() {
|
||||
return (
|
||||
<TopToolbar>
|
||||
@@ -103,6 +136,64 @@ function AdminUserActions({ record }: { record: AdminUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetStorageQuota() {
|
||||
const input = window.prompt(
|
||||
`请输入新的存储上限(支持 B/KB/MB/GB/TB,当前 ${formatLimitSize(record.storageQuotaBytes)})`,
|
||||
`${Math.floor(record.storageQuotaBytes / 1024 / 1024 / 1024)}GB`,
|
||||
);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const storageQuotaBytes = parseLimitInput(input);
|
||||
if (!storageQuotaBytes) {
|
||||
notify('输入格式不正确,请输入例如 20GB 或 21474836480', { type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiRequest(`/admin/users/${record.id}/storage-quota`, {
|
||||
method: 'PATCH',
|
||||
body: { storageQuotaBytes },
|
||||
});
|
||||
notify(`存储上限已更新为 ${formatLimitSize(storageQuotaBytes)}`, { type: 'success' });
|
||||
refresh();
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '存储上限更新失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetMaxUploadSize() {
|
||||
const input = window.prompt(
|
||||
`请输入单文件最大上传大小(支持 B/KB/MB/GB/TB,当前 ${formatLimitSize(record.maxUploadSizeBytes)})`,
|
||||
`${Math.max(1, Math.floor(record.maxUploadSizeBytes / 1024 / 1024))}MB`,
|
||||
);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const maxUploadSizeBytes = parseLimitInput(input);
|
||||
if (!maxUploadSizeBytes) {
|
||||
notify('输入格式不正确,请输入例如 500MB 或 524288000', { type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiRequest(`/admin/users/${record.id}/max-upload-size`, {
|
||||
method: 'PATCH',
|
||||
body: { maxUploadSizeBytes },
|
||||
});
|
||||
notify(`单文件上限已更新为 ${formatLimitSize(maxUploadSizeBytes)}`, { type: 'success' });
|
||||
refresh();
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '单文件上限更新失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`);
|
||||
if (!confirmed) {
|
||||
@@ -134,6 +225,12 @@ function AdminUserActions({ record }: { record: AdminUser }) {
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleResetPassword()}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetStorageQuota()}>
|
||||
存储上限
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetMaxUploadSize()}>
|
||||
单文件上限
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={record.banned ? 'contained' : 'outlined'}
|
||||
@@ -162,6 +259,14 @@ export function PortalAdminUsersList() {
|
||||
<TextField source="username" label="用户名" />
|
||||
<TextField source="email" label="邮箱" />
|
||||
<TextField source="phoneNumber" label="手机号" emptyText="-" />
|
||||
<FunctionField<AdminUser>
|
||||
label="存储上限"
|
||||
render={(record) => formatLimitSize(record.storageQuotaBytes)}
|
||||
/>
|
||||
<FunctionField<AdminUser>
|
||||
label="单文件上限"
|
||||
render={(record) => formatLimitSize(record.maxUploadSizeBytes)}
|
||||
/>
|
||||
<FunctionField<AdminUser>
|
||||
label="角色"
|
||||
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UserProfile {
|
||||
avatarUrl?: string | null;
|
||||
role?: AdminUserRole;
|
||||
createdAt: string;
|
||||
storageQuotaBytes?: number;
|
||||
maxUploadSizeBytes?: number;
|
||||
}
|
||||
|
||||
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
@@ -27,6 +29,8 @@ export interface AdminUser {
|
||||
createdAt: string;
|
||||
role: AdminUserRole;
|
||||
banned: boolean;
|
||||
storageQuotaBytes: number;
|
||||
maxUploadSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminFile {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Gamepad2, Rocket, Cat, Car, Play } from 'lucide-react';
|
||||
import { Gamepad2, Cat, Car, Play } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { calculateCardTilt } from './games-card-tilt';
|
||||
|
||||
const GAMES = [
|
||||
{
|
||||
@@ -24,6 +25,100 @@ const GAMES = [
|
||||
}
|
||||
];
|
||||
|
||||
function applyCardTilt(card: HTMLDivElement, rotateX: number, rotateY: number, glareX: number, glareY: number, scale: number) {
|
||||
card.style.setProperty('--card-rotate-x', `${rotateX}deg`);
|
||||
card.style.setProperty('--card-rotate-y', `${rotateY}deg`);
|
||||
card.style.setProperty('--card-glare-x', `${glareX}%`);
|
||||
card.style.setProperty('--card-glare-y', `${glareY}%`);
|
||||
card.style.setProperty('--card-scale', String(scale));
|
||||
}
|
||||
|
||||
function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = cardRef.current;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
const tilt = calculateCardTilt(
|
||||
{
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
},
|
||||
rect,
|
||||
);
|
||||
|
||||
applyCardTilt(card, tilt.rotateX, tilt.rotateY, tilt.glareX, tilt.glareY, tilt.scale);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const card = cardRef.current;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
const edgeTilt = calculateCardTilt(
|
||||
{
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
},
|
||||
rect,
|
||||
);
|
||||
|
||||
// Keep the highlight near the edge where the pointer exits and only flatten the card.
|
||||
applyCardTilt(card, 0, 0, edgeTilt.glareX, edgeTilt.glareY, 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="h-full [perspective:1400px]"
|
||||
>
|
||||
<Card
|
||||
ref={cardRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="group relative flex h-full flex-col overflow-hidden border-white/10 bg-white/[0.03] transition-[background-color,border-color,box-shadow,transform] duration-200 ease-out will-change-transform [transform:rotateX(var(--card-rotate-x,0deg))_rotateY(var(--card-rotate-y,0deg))_scale(var(--card-scale,1))] hover:border-white/20 hover:bg-white/[0.06] hover:shadow-[0_28px_80px_rgba(15,23,42,0.45)]"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at var(--card-glare-x,50%) var(--card-glare-y,50%), rgba(255,255,255,0.24), rgba(255,255,255,0.08) 18%, rgba(255,255,255,0) 52%)',
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(135deg,rgba(255,255,255,0.12),rgba(255,255,255,0)_38%,rgba(51,110,255,0.18)_100%)] opacity-70" />
|
||||
<div className={cn("absolute top-0 left-0 h-1 w-full bg-gradient-to-r", game.color)} />
|
||||
<CardHeader className="relative z-10 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br shadow-lg", game.color)}>
|
||||
<game.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<span className="rounded-md bg-white/5 px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||
{game.category}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="mt-4 text-xl">{game.name}</CardTitle>
|
||||
<CardDescription className="mt-2 line-clamp-2">
|
||||
{game.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10 mt-auto pt-4">
|
||||
<Button className="w-full gap-2 transition-all group-hover:bg-white group-hover:text-black">
|
||||
<Play className="h-4 w-4" fill="currentColor" /> Launch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Games() {
|
||||
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
|
||||
|
||||
@@ -73,35 +168,7 @@ export default function Games() {
|
||||
{/* Game Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{GAMES.map((game, index) => (
|
||||
<motion.div
|
||||
key={game.id}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="h-full flex flex-col hover:bg-white/[0.04] transition-colors group overflow-hidden relative">
|
||||
<div className={cn("absolute top-0 left-0 w-full h-1 bg-gradient-to-r", game.color)} />
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={cn("w-12 h-12 rounded-2xl flex items-center justify-center bg-gradient-to-br shadow-lg", game.color)}>
|
||||
<game.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 bg-white/5 px-2 py-1 rounded-md">
|
||||
{game.category}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-xl mt-4">{game.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{game.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto pt-4">
|
||||
<Button className="w-full gap-2 group-hover:bg-white group-hover:text-black transition-all">
|
||||
<Play className="w-4 h-4" fill="currentColor" /> Launch
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<GameCard key={game.id} game={game} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,8 +81,11 @@ export default function Overview() {
|
||||
() => rootFiles.filter((file) => !file.directory).reduce((sum, file) => sum + file.size, 0),
|
||||
[rootFiles],
|
||||
);
|
||||
const storageQuotaBytes = profile?.storageQuotaBytes && profile.storageQuotaBytes > 0
|
||||
? profile.storageQuotaBytes
|
||||
: 50 * 1024 * 1024 * 1024;
|
||||
const usedGb = usedBytes / 1024 / 1024 / 1024;
|
||||
const storagePercent = Math.min((usedGb / 50) * 100, 100);
|
||||
const storagePercent = Math.min((usedBytes / storageQuotaBytes) * 100, 100);
|
||||
const latestFile = recentFiles[0] ?? null;
|
||||
const profileDisplayName = profile?.displayName || profile?.username || '未登录';
|
||||
const profileAvatarFallback = profileDisplayName.charAt(0).toUpperCase();
|
||||
@@ -233,7 +236,7 @@ export default function Overview() {
|
||||
<MetricCard
|
||||
title="存储占用"
|
||||
value={`${storagePercent.toFixed(1)}%`}
|
||||
desc={`${usedGb.toFixed(2)} GB / 50 GB`}
|
||||
desc={`${formatFileSize(usedBytes)} / ${formatFileSize(storageQuotaBytes)}`}
|
||||
icon={Database}
|
||||
delay={0.4}
|
||||
/>
|
||||
|
||||
76
front/src/pages/games-card-tilt.test.ts
Normal file
76
front/src/pages/games-card-tilt.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { calculateCardTilt } from './games-card-tilt';
|
||||
|
||||
const rect = {
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 320,
|
||||
height: 240,
|
||||
};
|
||||
|
||||
test('calculateCardTilt keeps the card flat at the center', () => {
|
||||
assert.deepEqual(
|
||||
calculateCardTilt(
|
||||
{
|
||||
clientX: 260,
|
||||
clientY: 320,
|
||||
},
|
||||
rect,
|
||||
),
|
||||
{
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
glareX: 50,
|
||||
glareY: 50,
|
||||
scale: 1.02,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('calculateCardTilt changes direction based on mouse position', () => {
|
||||
const topLeft = calculateCardTilt(
|
||||
{
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
},
|
||||
rect,
|
||||
);
|
||||
const bottomRight = calculateCardTilt(
|
||||
{
|
||||
clientX: 420,
|
||||
clientY: 440,
|
||||
},
|
||||
rect,
|
||||
);
|
||||
|
||||
assert.equal(topLeft.rotateX, 12);
|
||||
assert.equal(topLeft.rotateY, -12);
|
||||
assert.equal(topLeft.glareX, 0);
|
||||
assert.equal(topLeft.glareY, 0);
|
||||
|
||||
assert.equal(bottomRight.rotateX, -12);
|
||||
assert.equal(bottomRight.rotateY, 12);
|
||||
assert.equal(bottomRight.glareX, 100);
|
||||
assert.equal(bottomRight.glareY, 100);
|
||||
});
|
||||
|
||||
test('calculateCardTilt clamps mouse coordinates outside the card bounds', () => {
|
||||
assert.deepEqual(
|
||||
calculateCardTilt(
|
||||
{
|
||||
clientX: 600,
|
||||
clientY: 100,
|
||||
},
|
||||
rect,
|
||||
),
|
||||
{
|
||||
rotateX: 12,
|
||||
rotateY: 12,
|
||||
glareX: 100,
|
||||
glareY: 0,
|
||||
scale: 1.02,
|
||||
},
|
||||
);
|
||||
});
|
||||
57
front/src/pages/games-card-tilt.ts
Normal file
57
front/src/pages/games-card-tilt.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const DEFAULT_MAX_TILT = 12;
|
||||
const DEFAULT_SCALE = 1.02;
|
||||
|
||||
export interface CardPointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface CardRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface CardTiltState {
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
glareX: number;
|
||||
glareY: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
export function calculateCardTilt(
|
||||
pointer: CardPointerPosition,
|
||||
rect: CardRect,
|
||||
maxTilt = DEFAULT_MAX_TILT,
|
||||
): CardTiltState {
|
||||
const relativeX = clamp((pointer.clientX - rect.left) / rect.width, 0, 1);
|
||||
const relativeY = clamp((pointer.clientY - rect.top) / rect.height, 0, 1);
|
||||
|
||||
return {
|
||||
rotateX: round((0.5 - relativeY) * maxTilt * 2),
|
||||
rotateY: round((relativeX - 0.5) * maxTilt * 2),
|
||||
glareX: round(relativeX * 100),
|
||||
glareY: round(relativeY * 100),
|
||||
scale: DEFAULT_SCALE,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRestingCardTilt(): CardTiltState {
|
||||
return {
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
glareX: 50,
|
||||
glareY: 50,
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user