Migrate storage to DogeCloud and expand admin dashboard

This commit is contained in:
yoyuzh
2026-04-02 12:20:50 +08:00
parent 2424fbd2a7
commit 97edc4cc32
65 changed files with 2842 additions and 380 deletions

View File

@@ -8,6 +8,7 @@ import Files from './pages/Files';
import Transfer from './pages/Transfer';
import FileShare from './pages/FileShare';
import Games from './pages/Games';
import GamePlayer from './pages/GamePlayer';
import { FILE_SHARE_ROUTE_PREFIX } from './lib/file-share';
import {
getTransferRouterMode,
@@ -58,6 +59,7 @@ function AppRoutes() {
<Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} />
<Route path="games" element={<Games />} />
<Route path="games/:gameId" element={<GamePlayer />} />
</Route>
<Route
path="/admin/*"

View File

@@ -1,13 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getInviteCodePanelState } from './dashboard-state';
import {
buildRequestLineChartModel,
formatMetricValue,
getInviteCodePanelState,
parseStorageLimitInput,
} from './dashboard-state';
test('getInviteCodePanelState returns a copyable invite code when summary contains one', () => {
assert.deepEqual(
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: ' AbCd1234 ',
}),
{
@@ -22,6 +34,13 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
getInviteCodePanelState({
totalUsers: 12,
totalFiles: 34,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: ' ',
}),
{
@@ -30,3 +49,41 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
},
);
});
test('formatMetricValue formats byte metrics with binary units', () => {
assert.equal(formatMetricValue(1536, 'bytes'), '1.5 KB');
assert.equal(formatMetricValue(50 * 1024 * 1024 * 1024, 'bytes'), '50 GB');
});
test('formatMetricValue formats count metrics with locale separators', () => {
assert.equal(formatMetricValue(1234567, 'count'), '1,234,567');
});
test('parseStorageLimitInput accepts common storage unit inputs', () => {
assert.equal(parseStorageLimitInput('20GB'), 20 * 1024 * 1024 * 1024);
assert.equal(parseStorageLimitInput('512 mb'), 512 * 1024 * 1024);
});
test('parseStorageLimitInput rejects invalid or non-positive inputs', () => {
assert.equal(parseStorageLimitInput('0GB'), null);
assert.equal(parseStorageLimitInput('abc'), null);
});
test('buildRequestLineChartModel converts hourly request data into chart coordinates', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 0 },
{ hour: 1, label: '01:00', requestCount: 30 },
{ hour: 2, label: '02:00', requestCount: 60 },
{ hour: 3, label: '03:00', requestCount: 15 },
]);
assert.equal(model.points.length, 4);
assert.equal(model.points[0]?.x, 0);
assert.equal(model.points[0]?.y, 100);
assert.equal(model.points[2]?.y, 0);
assert.equal(model.points[3]?.x, 100);
assert.equal(model.maxValue, 60);
assert.equal(model.linePath, 'M 0 100 L 33.333 50 L 66.667 0 L 100 75');
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
assert.equal(model.peakPoint?.label, '02:00');
});

View File

@@ -1,10 +1,108 @@
import type { AdminSummary } from '@/src/lib/types';
import type { AdminRequestTimelinePoint, AdminSummary } from '@/src/lib/types';
export interface InviteCodePanelState {
inviteCode: string;
canCopy: boolean;
}
export interface RequestLineChartPoint extends AdminRequestTimelinePoint {
x: number;
y: number;
}
export interface RequestLineChartModel {
points: RequestLineChartPoint[];
linePath: string;
areaPath: string;
yAxisTicks: number[];
maxValue: number;
peakPoint: RequestLineChartPoint | null;
}
type MetricValueKind = 'bytes' | 'count';
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
export function formatMetricValue(value: number, kind: MetricValueKind): string {
if (kind === 'count') {
return new Intl.NumberFormat('en-US').format(value);
}
if (value <= 0) {
return '0 B';
}
const unitIndex = Math.min(Math.floor(Math.log(value) / Math.log(1024)), BYTE_UNITS.length - 1);
const unitValue = value / 1024 ** unitIndex;
const formatted = unitValue >= 10 || unitIndex === 0 ? unitValue.toFixed(0) : unitValue.toFixed(1);
return `${formatted} ${BYTE_UNITS[unitIndex]}`;
}
export function parseStorageLimitInput(value: string): number | null {
const normalized = value.trim().toLowerCase();
const matched = normalized.match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|tb|pb)?$/);
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 === 'pb'
? 1024 ** 5
: unit === 'tb'
? 1024 ** 4
: unit === 'gb'
? 1024 ** 3
: unit === 'mb'
? 1024 ** 2
: unit === 'kb'
? 1024
: 1;
return Math.floor(amount * multiplier);
}
export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]): RequestLineChartModel {
if (timeline.length === 0) {
return {
points: [],
linePath: '',
areaPath: '',
yAxisTicks: [0, 1, 2, 3, 4],
maxValue: 0,
peakPoint: null,
};
}
const maxValue = Math.max(...timeline.map((point) => point.requestCount), 0);
const scaleMax = maxValue > 0 ? maxValue : 1;
const lastIndex = Math.max(timeline.length - 1, 1);
const points = timeline.map((point, index) => ({
...point,
x: roundChartValue((index / lastIndex) * 100),
y: roundChartValue(100 - (point.requestCount / scaleMax) * 100),
}));
const linePath = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${formatChartNumber(point.x)} ${formatChartNumber(point.y)}`)
.join(' ');
return {
points,
linePath,
areaPath: linePath ? `${linePath} L 100 100 L 0 100 Z` : '',
yAxisTicks: buildYAxisTicks(maxValue),
maxValue,
peakPoint: points.reduce<RequestLineChartPoint | null>((peak, point) => {
if (!peak || point.requestCount > peak.requestCount) {
return point;
}
return peak;
}, null),
};
}
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
const inviteCode = summary?.inviteCode?.trim() ?? '';
if (!inviteCode) {
@@ -19,3 +117,19 @@ export function getInviteCodePanelState(summary: AdminSummary | null | undefined
canCopy: true,
};
}
function buildYAxisTicks(maxValue: number): number[] {
if (maxValue <= 0) {
return [0, 1, 2, 3, 4];
}
return Array.from({ length: 5 }, (_, index) => roundChartValue((maxValue / 4) * index));
}
function roundChartValue(value: number): number {
return Math.round(value * 1000) / 1000;
}
function formatChartNumber(value: number): string {
const rounded = roundChartValue(value);
return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
}

View File

@@ -1,35 +1,409 @@
import { useEffect, useState } from 'react';
import ArchiveRoundedIcon from '@mui/icons-material/ArchiveRounded';
import BoltRoundedIcon from '@mui/icons-material/BoltRounded';
import CloudDownloadRoundedIcon from '@mui/icons-material/CloudDownloadRounded';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import FolderRoundedIcon from '@mui/icons-material/FolderRounded';
import HubRoundedIcon from '@mui/icons-material/HubRounded';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Alert, Button, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
import StorageRoundedIcon from '@mui/icons-material/StorageRounded';
import { Alert, Box, Button, Card, CardContent, CircularProgress, Grid, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { apiRequest } from '@/src/lib/api';
import { readStoredSession } from '@/src/lib/session';
import type { AdminSummary } from '@/src/lib/types';
import { getInviteCodePanelState } from './dashboard-state';
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
interface DashboardState {
summary: AdminSummary | null;
}
const DASHBOARD_ITEMS = [
{
title: '文件资源',
description: '已接入 /api/admin/files 与 /api/admin/files/{id} 删除接口,可查看全站文件元数据。',
status: 'connected',
},
{
title: '用户管理',
description: '已接入 /api/admin/users可查看账号、邮箱、手机号与权限状态。',
status: 'connected',
},
{
title: '门户运营',
description: '当前后台专注于统一账号和文件资源,保持管理视图聚焦在核心门户能力上。',
status: 'connected',
},
];
interface MetricCardDefinition {
key: string;
title: string;
scope: string;
accent: string;
icon: React.ReactNode;
value: string;
helper: string;
}
const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]);
const DASHBOARD_CARD_BG = '#111827';
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
const DASHBOARD_CARD_TEXT = '#f8fafc';
const DASHBOARD_CARD_MUTED_TEXT = 'rgba(226, 232, 240, 0.72)';
function DashboardMetricCard({ metric }: { metric: MetricCardDefinition }) {
return (
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
height: '100%',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
inset: '0 auto 0 0',
width: 4,
backgroundColor: metric.accent,
},
})}
>
<CardContent sx={{ height: '100%', pl: 2.5 }}>
<Stack spacing={1.25} sx={{ height: '100%' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box
sx={{
width: 42,
height: 42,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: metric.accent,
backgroundColor: `${metric.accent}14`,
}}
>
{metric.icon}
</Box>
<Typography
variant="caption"
sx={{
px: 1,
py: 0.4,
borderRadius: 99,
color: metric.accent,
backgroundColor: `${metric.accent}12`,
fontWeight: 700,
}}
>
{metric.scope}
</Typography>
</Stack>
<Stack spacing={0.75}>
<Typography
variant="subtitle2"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
fontWeight: 700,
})}
>
{metric.title}
</Typography>
<Typography
variant="h3"
sx={(theme) => ({
fontWeight: 800,
lineHeight: 1.05,
letterSpacing: '-0.02em',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
{metric.value}
</Typography>
</Stack>
<Typography
variant="body2"
sx={(theme) => ({
mt: 'auto',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{metric.helper}
</Typography>
</Stack>
</CardContent>
</Card>
);
}
function RequestTrendChart({ summary }: { summary: AdminSummary }) {
const chart = buildRequestLineChartModel(summary.requestTimeline);
const currentHour = new Date().getHours();
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour));
const hasRequests = chart.maxValue > 0;
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
return (
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
})}
>
<CardContent>
<Stack spacing={2.5}>
<Stack
direction={{ xs: 'column', lg: 'row' }}
spacing={2}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', lg: 'center' }}
>
<Stack spacing={0.75}>
<Typography variant="h6" fontWeight={700}>
线
</Typography>
<Typography
variant="body2"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
`/api/**` 便
</Typography>
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
<Stack
spacing={0.35}
sx={{
minWidth: 132,
px: 1.5,
py: 1.25,
borderRadius: 2,
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.06)' : 'action.hover',
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid transparent',
}}
>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
fontWeight={700}
>
</Typography>
<Typography
variant="h6"
fontWeight={800}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
{formatMetricValue(currentPoint?.requestCount ?? 0, 'count')}
</Typography>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{currentPoint?.label ?? '--'}
</Typography>
</Stack>
<Stack
spacing={0.35}
sx={{
minWidth: 132,
px: 1.5,
py: 1.25,
borderRadius: 2,
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(37, 99, 235, 0.14)' : '#eff6ff',
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(96, 165, 250, 0.2)' : '1px solid transparent',
}}
>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
fontWeight={700}
>
</Typography>
<Typography
variant="h6"
fontWeight={800}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
{formatMetricValue(chart.peakPoint?.requestCount ?? 0, 'count')}
</Typography>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{chart.peakPoint?.label ?? '--'}
</Typography>
</Stack>
</Stack>
</Stack>
<Box
sx={{
p: { xs: 1.5, md: 2 },
borderRadius: 3,
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)',
background: (theme) =>
theme.palette.mode === 'dark'
? 'linear-gradient(180deg, rgba(15, 23, 42, 0.72) 0%, rgba(17, 24, 39, 0.94) 100%)'
: 'linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%)',
}}
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '56px minmax(0, 1fr)' },
gap: 1.5,
alignItems: 'stretch',
}}
>
<Stack
spacing={0}
justifyContent="space-between"
sx={{ py: 1.25, display: { xs: 'none', md: 'flex' } }}
>
{chart.yAxisTicks.slice().reverse().map((tick) => (
<Typography
key={tick}
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{formatMetricValue(tick, 'count')}
</Typography>
))}
</Stack>
<Stack spacing={1.25}>
<Box
sx={{
position: 'relative',
height: { xs: 220, md: 280 },
borderRadius: 2.5,
overflow: 'hidden',
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(15, 23, 42, 0.58)' : 'rgba(255, 255, 255, 0.72)',
}}
>
<Box component="svg" viewBox="0 0 100 100" preserveAspectRatio="none" sx={{ display: 'block', width: '100%', height: '100%' }}>
<defs>
<linearGradient id="request-trend-area" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.28" />
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.02" />
</linearGradient>
</defs>
{chart.yAxisTicks.map((tick) => {
const y = 100 - (tick / scaleMax) * 100;
return (
<line
key={tick}
x1="0"
x2="100"
y1={y}
y2={y}
stroke="rgba(148, 163, 184, 0.28)"
strokeDasharray="3 4"
vectorEffect="non-scaling-stroke"
/>
);
})}
{currentPoint && (
<line
x1={currentPoint.x}
x2={currentPoint.x}
y1="0"
y2="100"
stroke="rgba(15, 23, 42, 0.18)"
strokeDasharray="2 4"
vectorEffect="non-scaling-stroke"
/>
)}
{chart.areaPath && <path d={chart.areaPath} fill="url(#request-trend-area)" />}
{chart.linePath && (
<path
d={chart.linePath}
fill="none"
stroke="#2563eb"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
)}
{chart.points.map((point) => (
<circle
key={point.label}
cx={point.x}
cy={point.y}
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
stroke="#ffffff"
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
))}
</Box>
{!hasRequests && (
<Stack
spacing={0.4}
alignItems="center"
justifyContent="center"
sx={{
position: 'absolute',
inset: 0,
color: (theme) => theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(15, 23, 42, 0.82)' : 'rgba(248, 250, 252, 0.68)',
}}
>
<Typography
variant="subtitle2"
fontWeight={700}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
</Typography>
<Typography variant="body2">
线
</Typography>
</Stack>
)}
</Box>
<Stack direction="row" justifyContent="space-between" sx={{ px: 0.5 }}>
{xAxisPoints.map((point) => (
<Typography
key={point.label}
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{point.label}
</Typography>
))}
</Stack>
</Stack>
</Box>
</Box>
</Stack>
</CardContent>
</Card>
);
}
export function PortalAdminDashboard() {
const [state, setState] = useState<DashboardState>({
@@ -37,7 +411,9 @@ export function PortalAdminDashboard() {
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [copyMessage, setCopyMessage] = useState('');
const [updatingLimit, setUpdatingLimit] = useState(false);
const navigate = useNavigate();
const session = readStoredSession();
@@ -47,10 +423,7 @@ export function PortalAdminDashboard() {
try {
const summary = await apiRequest<AdminSummary>('/admin/summary');
setState({
summary,
});
setState({ summary });
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
} finally {
@@ -59,44 +432,68 @@ export function PortalAdminDashboard() {
}
useEffect(() => {
let active = true;
void (async () => {
setLoading(true);
setError('');
try {
const summary = await apiRequest<AdminSummary>('/admin/summary');
if (!active) {
return;
}
setState({
summary,
});
} catch (requestError) {
if (!active) {
return;
}
setError(requestError instanceof Error ? requestError.message : '后台首页数据加载失败');
} finally {
if (active) {
setLoading(false);
}
}
})();
return () => {
active = false;
};
void loadDashboardData();
}, []);
const inviteCodePanel = getInviteCodePanelState(state.summary);
const summary = state.summary;
const metrics: MetricCardDefinition[] = summary ? [
{
key: 'total-storage',
title: '总存储量',
scope: '累计',
value: formatMetricValue(summary.totalStorageBytes, 'bytes'),
helper: `全站普通文件 ${formatMetricValue(summary.totalFiles, 'count')} 个。`,
accent: '#0f766e',
icon: <StorageRoundedIcon />,
},
{
key: 'download-traffic',
title: '下载流量',
scope: '累计',
value: formatMetricValue(summary.downloadTrafficBytes, 'bytes'),
helper: '文件下载和离线快传下载都会计入这里。',
accent: '#2563eb',
icon: <CloudDownloadRoundedIcon />,
},
{
key: 'request-count',
title: '今日请求次数',
scope: '今日',
value: formatMetricValue(summary.requestCount, 'count'),
helper: '只统计今天的 `/api/**` 请求,不再显示累计值。',
accent: '#d97706',
icon: <HubRoundedIcon />,
},
{
key: 'transfer-usage',
title: '快传使用量',
scope: '累计',
value: formatMetricValue(summary.transferUsageBytes, 'bytes'),
helper: '按快传会话申报的文件体积累计统计。',
accent: '#7c3aed',
icon: <BoltRoundedIcon />,
},
{
key: 'offline-transfer-storage',
title: '快传离线存储量',
scope: '当前',
value: formatMetricValue(summary.offlineTransferStorageBytes, 'bytes'),
helper: `当前上限 ${formatMetricValue(summary.offlineTransferStorageLimitBytes, 'bytes')}`,
accent: '#be123c',
icon: (
<Stack direction="row" spacing={0.5} alignItems="center">
<ArchiveRoundedIcon fontSize="small" />
<BoltRoundedIcon fontSize="small" />
</Stack>
),
},
] : [];
async function handleRefreshInviteCode() {
setCopyMessage('');
setSuccessMessage('');
await loadDashboardData();
}
@@ -118,6 +515,42 @@ export function PortalAdminDashboard() {
}
}
async function handleUpdateOfflineTransferLimit() {
if (!summary) {
return;
}
const input = window.prompt(
`请输入新的离线快传存储上限(支持 B/KB/MB/GB/TB当前 ${formatMetricValue(summary.offlineTransferStorageLimitBytes, 'bytes')}`,
`${Math.max(1, Math.floor(summary.offlineTransferStorageLimitBytes / 1024 / 1024 / 1024))}GB`,
);
if (!input) {
return;
}
const offlineTransferStorageLimitBytes = parseStorageLimitInput(input);
if (!offlineTransferStorageLimitBytes) {
setError('输入格式不正确,请输入例如 20GB 或 21474836480');
return;
}
setUpdatingLimit(true);
setError('');
setSuccessMessage('');
try {
const result = await apiRequest<AdminOfflineTransferStorageLimitResponse>('/admin/settings/offline-transfer-storage-limit', {
method: 'PATCH',
body: { offlineTransferStorageLimitBytes },
});
setSuccessMessage(`离线快传存储上限已更新为 ${formatMetricValue(result.offlineTransferStorageLimitBytes, 'bytes')}`);
await loadDashboardData();
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : '离线快传存储上限更新失败');
} finally {
setUpdatingLimit(false);
}
}
return (
<Stack spacing={3} sx={{ p: 2 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} justifyContent="space-between" alignItems={{ xs: 'flex-start', sm: 'center' }}>
@@ -126,7 +559,7 @@ export function PortalAdminDashboard() {
YOYUZH Admin
</Typography>
<Typography color="text.secondary">
react-admin `/api/admin/**`
</Typography>
</Stack>
<Button variant="outlined" onClick={() => navigate('/overview')}>
@@ -142,61 +575,53 @@ export function PortalAdminDashboard() {
)}
{error && <Alert severity="error">{error}</Alert>}
{successMessage && <Alert severity="success">{successMessage}</Alert>}
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
<Grid container spacing={2}>
{DASHBOARD_ITEMS.map((item) => (
<Grid key={item.title} size={{ xs: 12, md: 4 }}>
<Card variant="outlined">
<CardContent>
<Stack spacing={1.5}>
<Chip label={item.status} size="small" color="primary" sx={{ width: 'fit-content' }} />
<Typography variant="h6" fontWeight={600}>
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.description}
</Typography>
</Stack>
</CardContent>
</Card>
{metrics.map((metric) => (
<Grid key={metric.key} size={{ xs: 12, sm: 6, xl: 2.4 }}>
<DashboardMetricCard metric={metric} />
</Grid>
))}
</Grid>
{summary && <RequestTrendChart summary={summary} />}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined">
<CardContent>
<Stack spacing={1}>
<Typography variant="h6" fontWeight={600}>
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
height: '100%',
})}
>
<CardContent sx={{ height: '100%' }}>
<Stack spacing={1.25} sx={{ height: '100%' }}>
<Typography
variant="h6"
fontWeight={600}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
</Typography>
<Typography color="text.secondary">
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{session?.user.username ?? '-'}
</Typography>
<Typography color="text.secondary">
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{session?.user.email ?? '-'}
</Typography>
<Typography color="text.secondary">
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
ID{session?.user.id ?? '-'}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined">
<CardContent>
<Stack spacing={1}>
<Typography variant="h6" fontWeight={600}>
</Typography>
<Typography color="text.secondary">
{state.summary?.totalUsers ?? 0}
</Typography>
<Typography color="text.secondary">
{state.summary?.totalFiles ?? 0}
<Typography sx={(theme) => ({ mt: 'auto', color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{formatMetricValue(summary?.totalUsers ?? 0, 'count')} {formatMetricValue(summary?.totalFiles ?? 0, 'count')}
</Typography>
</Stack>
</CardContent>
@@ -204,31 +629,95 @@ export function PortalAdminDashboard() {
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card variant="outlined">
<CardContent>
<Stack spacing={1.5}>
<Typography variant="h6" fontWeight={600}>
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
height: '100%',
})}
>
<CardContent sx={{ height: '100%' }}>
<Stack spacing={1.5} sx={{ height: '100%' }}>
<Typography
variant="h6"
fontWeight={600}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
线
</Typography>
<Typography color="text.secondary">
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
线{formatMetricValue(summary?.offlineTransferStorageBytes ?? 0, 'bytes')}
</Typography>
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
{formatMetricValue(summary?.offlineTransferStorageLimitBytes ?? 0, 'bytes')}
</Typography>
<Typography variant="body2" sx={(theme) => ({ mt: 'auto', color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
线线
</Typography>
<Button
variant="contained"
size="small"
startIcon={<EditRoundedIcon />}
disabled={updatingLimit || !summary}
onClick={() => void handleUpdateOfflineTransferLimit()}
>
线
</Button>
</Stack>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
height: '100%',
})}
>
<CardContent sx={{ height: '100%' }}>
<Stack spacing={1.5} sx={{ height: '100%' }}>
<Stack direction="row" spacing={1} alignItems="center">
<FolderRoundedIcon color="primary" />
<Typography
variant="h6"
fontWeight={600}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
</Typography>
</Stack>
<Typography sx={(theme) => ({ color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary' })}>
</Typography>
<Typography
component="code"
sx={{
sx={(theme) => ({
display: 'inline-block',
width: 'fit-content',
px: 1.5,
py: 1,
borderRadius: 1,
backgroundColor: 'action.hover',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'action.hover',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: '0.95rem',
}}
})}
>
{inviteCodePanel.inviteCode}
</Typography>
<Stack direction="row" spacing={1}>
<Stack direction="row" spacing={1} sx={{ mt: 'auto' }}>
<Button
variant="contained"
size="small"
@@ -243,12 +732,10 @@ export function PortalAdminDashboard() {
size="small"
startIcon={<RefreshIcon />}
onClick={() => void handleRefreshInviteCode()}
disabled={loading}
>
</Button>
</Stack>
{copyMessage && <Alert severity="success">{copyMessage}</Alert>}
</Stack>
</CardContent>
</Card>

View File

@@ -59,6 +59,10 @@ function UsersListActions() {
);
}
function formatStorageUsage(usedBytes: number, quotaBytes: number) {
return `${formatLimitSize(usedBytes)} / ${formatLimitSize(quotaBytes)}`;
}
function AdminUserActions({ record }: { record: AdminUser }) {
const notify = useNotify();
const refresh = useRefresh();
@@ -116,7 +120,7 @@ function AdminUserActions({ record }: { record: AdminUser }) {
async function handleSetPassword() {
const newPassword = window.prompt(
'请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。',
'请输入新密码。密码至少8位,且必须包含大写字母。',
);
if (!newPassword) {
return;
@@ -215,20 +219,20 @@ function AdminUserActions({ record }: { record: AdminUser }) {
}
return (
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleRoleAssign()}>
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap">
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleRoleAssign()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetPassword()}>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetPassword()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleResetPassword()}>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleResetPassword()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetStorageQuota()}>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetStorageQuota()}>
</Button>
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetMaxUploadSize()}>
<Button size="small" variant="outlined" disabled={busy} sx={{ minWidth: 'auto', px: 1 }} onClick={() => void handleSetMaxUploadSize()}>
</Button>
<Button
@@ -236,6 +240,7 @@ function AdminUserActions({ record }: { record: AdminUser }) {
variant={record.banned ? 'contained' : 'outlined'}
color={record.banned ? 'success' : 'warning'}
disabled={busy}
sx={{ minWidth: 'auto', px: 1 }}
onClick={() => void handleToggleBan()}
>
{record.banned ? '解封' : '封禁'}
@@ -254,14 +259,24 @@ export function PortalAdminUsersList() {
title="用户管理"
sort={{ field: 'createdAt', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false} rowClick={false}>
<Datagrid
bulkActionButtons={false}
rowClick={false}
size="small"
sx={{
'& .RaDatagrid-table th, & .RaDatagrid-table td': {
px: 1,
py: 0.75,
},
}}
>
<TextField source="id" label="ID" />
<TextField source="username" label="用户名" />
<TextField source="email" label="邮箱" />
<TextField source="phoneNumber" label="手机号" emptyText="-" />
<FunctionField<AdminUser>
label="存储上限"
render={(record) => formatLimitSize(record.storageQuotaBytes)}
label="存储使用"
render={(record) => formatStorageUsage(record.usedStorageBytes, record.storageQuotaBytes)}
/>
<FunctionField<AdminUser>
label="单文件上限"

View File

@@ -9,6 +9,13 @@ test('fetchAdminAccessStatus returns true when the admin summary request succeed
const request = async () => ({
totalUsers: 1,
totalFiles: 2,
totalStorageBytes: 0,
downloadTrafficBytes: 0,
requestCount: 0,
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
requestTimeline: [],
inviteCode: 'invite-code',
});

View File

@@ -15,12 +15,29 @@ export interface UserProfile {
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
export interface AdminRequestTimelinePoint {
hour: number;
label: string;
requestCount: number;
}
export interface AdminSummary {
totalUsers: number;
totalFiles: number;
totalStorageBytes: number;
downloadTrafficBytes: number;
requestCount: number;
transferUsageBytes: number;
offlineTransferStorageBytes: number;
offlineTransferStorageLimitBytes: number;
requestTimeline: AdminRequestTimelinePoint[];
inviteCode: string;
}
export interface AdminOfflineTransferStorageLimitResponse {
offlineTransferStorageLimitBytes: number;
}
export interface AdminUser {
id: number;
username: string;
@@ -29,6 +46,7 @@ export interface AdminUser {
createdAt: string;
role: AdminUserRole;
banned: boolean;
usedStorageBytes: number;
storageQuotaBytes: number;
maxUploadSizeBytes: number;
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ExternalLink } from 'lucide-react';
import { Button } from '@/src/components/ui/button';
import { GAME_EXIT_PATH, isGameId, resolveGameHref } from './games-links';
export default function GamePlayer() {
const navigate = useNavigate();
const { gameId } = useParams<{ gameId: string }>();
if (!gameId || !isGameId(gameId)) {
return <Navigate to={GAME_EXIT_PATH} replace />;
}
const gameHref = resolveGameHref(gameId);
return (
<div className="space-y-6">
<div className="glass-panel relative overflow-hidden rounded-3xl p-4 shadow-xl md:p-6">
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-white/10 to-transparent" />
<div className="relative z-10 flex items-center justify-between gap-3">
<div>
<div className="text-xs uppercase tracking-[0.32em] text-slate-400">Game Session</div>
<h1 className="mt-2 text-2xl font-bold text-white">{gameId.toUpperCase()}</h1>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="glass"
className="gap-2"
onClick={() => navigate(GAME_EXIT_PATH)}
>
<ArrowLeft className="h-4 w-4" />
退
</Button>
<Button
type="button"
variant="glass"
className="gap-2"
onClick={() => window.open(gameHref, '_blank', 'noopener,noreferrer')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div className="glass-panel relative mt-5 overflow-hidden rounded-2xl p-1 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
<div className="relative overflow-hidden rounded-xl bg-black">
<Button
type="button"
variant="glass"
className="absolute left-4 top-4 z-10 gap-2 shadow-lg"
onClick={() => navigate(GAME_EXIT_PATH)}
>
<ArrowLeft className="h-4 w-4" />
退
</Button>
<iframe
title={`${gameId} game`}
src={gameHref}
className="h-[70vh] min-h-[560px] w-full border-0"
/>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,21 @@
import React, { useRef, useState } from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Gamepad2, Cat, Car, Play } from 'lucide-react';
import { Gamepad2, Cat, Car, ExternalLink, Play } from 'lucide-react';
import { cn } from '@/src/lib/utils';
import { calculateCardTilt } from './games-card-tilt';
import { MORE_GAMES_LABEL, MORE_GAMES_URL, resolveGamePlayerPath, type GameId } from './games-links';
const GAMES = [
const GAMES: Array<{
id: GameId;
name: string;
description: string;
icon: typeof Cat;
color: string;
category: 'featured';
}> = [
{
id: 'cat',
name: 'CAT',
@@ -34,6 +43,7 @@ function applyCardTilt(card: HTMLDivElement, rotateX: number, rotateY: number, g
}
function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number }) {
const navigate = useNavigate();
const cardRef = useRef<HTMLDivElement | null>(null);
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -110,7 +120,11 @@ function GameCard({ game, index }: { game: (typeof GAMES)[number]; index: number
</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">
<Button
type="button"
onClick={() => navigate(resolveGamePlayerPath(game.id))}
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>
@@ -140,6 +154,15 @@ export default function Games() {
<p className="text-sm text-slate-400 leading-relaxed">
</p>
<a
href={MORE_GAMES_URL}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-sm text-slate-300 transition-colors hover:text-white"
>
<ExternalLink className="h-4 w-4" />
{MORE_GAMES_LABEL}
</a>
</div>
</motion.div>

View File

@@ -331,12 +331,12 @@ export default function Login() {
value={registerPassword}
onChange={(event) => setRegisterPassword(event.target.value)}
required
minLength={10}
minLength={8}
maxLength={64}
/>
</div>
<p className="text-xs text-slate-500 ml-1">
10
8
</p>
</div>
<div className="space-y-2">
@@ -350,7 +350,7 @@ export default function Login() {
value={registerConfirmPassword}
onChange={(event) => setRegisterConfirmPassword(event.target.value)}
required
minLength={10}
minLength={8}
maxLength={64}
/>
</div>

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GAME_EXIT_PATH,
MORE_GAMES_LABEL,
MORE_GAMES_URL,
isGameId,
resolveGameHref,
resolveGamePlayerPath,
} from './games-links';
test('resolveGameHref maps the cat game to the t_race OSS page', () => {
assert.equal(resolveGameHref('cat'), '/t_race/');
});
test('resolveGameHref maps the race game to the race OSS page', () => {
assert.equal(resolveGameHref('race'), '/race/');
});
test('resolveGamePlayerPath maps the cat game to the in-app player route', () => {
assert.equal(resolveGamePlayerPath('cat'), '/games/cat');
});
test('resolveGamePlayerPath maps the race game to the in-app player route', () => {
assert.equal(resolveGamePlayerPath('race'), '/games/race');
});
test('isGameId only accepts supported game ids', () => {
assert.equal(isGameId('cat'), true);
assert.equal(isGameId('race'), true);
assert.equal(isGameId('t_race'), false);
});
test('GAME_EXIT_PATH points back to the games lobby', () => {
assert.equal(GAME_EXIT_PATH, '/games');
});
test('MORE_GAMES_URL points to the requested external games site', () => {
assert.equal(MORE_GAMES_URL, 'https://quruifps.xyz');
});
test('MORE_GAMES_LABEL keeps the friendly-link copy', () => {
assert.equal(MORE_GAMES_LABEL, '更多游戏请访问quruifps.xyz');
});

View File

@@ -0,0 +1,21 @@
const GAME_HREFS = {
cat: '/t_race/',
race: '/race/',
} as const;
export type GameId = keyof typeof GAME_HREFS;
export const GAME_EXIT_PATH = '/games';
export const MORE_GAMES_URL = 'https://quruifps.xyz';
export const MORE_GAMES_LABEL = '更多游戏请访问quruifps.xyz';
export function resolveGameHref(gameId: GameId) {
return GAME_HREFS[gameId];
}
export function resolveGamePlayerPath(gameId: GameId) {
return `${GAME_EXIT_PATH}/${gameId}`;
}
export function isGameId(value: string): value is GameId {
return value in GAME_HREFS;
}