Migrate storage to DogeCloud and expand admin dashboard
This commit is contained in:
@@ -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/*"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(/\.$/, '');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="单文件上限"
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
71
front/src/pages/GamePlayer.tsx
Normal file
71
front/src/pages/GamePlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
45
front/src/pages/games-links.test.ts
Normal file
45
front/src/pages/games-links.test.ts
Normal 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');
|
||||
});
|
||||
21
front/src/pages/games-links.ts
Normal file
21
front/src/pages/games-links.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user