添加账号修改,后台管理
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { useAuth } from './auth/AuthProvider';
|
||||
@@ -8,6 +8,8 @@ import Files from './pages/Files';
|
||||
import School from './pages/School';
|
||||
import Games from './pages/Games';
|
||||
|
||||
const PortalAdminApp = React.lazy(() => import('./admin/AdminApp'));
|
||||
|
||||
function AppRoutes() {
|
||||
const { ready, session } = useAuth();
|
||||
|
||||
@@ -37,6 +39,24 @@ function AppRoutes() {
|
||||
<Route path="school" element={<School />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="min-h-screen flex items-center justify-center bg-white text-slate-700">
|
||||
正在加载后台管理台...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PortalAdminApp />
|
||||
</Suspense>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={isAuthenticated ? '/overview' : '/login'} replace />}
|
||||
|
||||
47
front/src/admin/AdminApp.tsx
Normal file
47
front/src/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import FolderOutlined from '@mui/icons-material/FolderOutlined';
|
||||
import GroupsOutlined from '@mui/icons-material/GroupsOutlined';
|
||||
import SchoolOutlined from '@mui/icons-material/SchoolOutlined';
|
||||
import { Admin, Resource } from 'react-admin';
|
||||
|
||||
import { portalAdminAuthProvider } from './auth-provider';
|
||||
import { portalAdminDataProvider } from './data-provider';
|
||||
import { PortalAdminDashboard } from './dashboard';
|
||||
import { PortalAdminFilesList } from './files-list';
|
||||
import { PortalAdminUsersList } from './users-list';
|
||||
import { PortalAdminSchoolSnapshotsList } from './school-snapshots-list';
|
||||
|
||||
export default function PortalAdminApp() {
|
||||
return (
|
||||
<Admin
|
||||
authProvider={portalAdminAuthProvider}
|
||||
basename="/admin"
|
||||
dashboard={PortalAdminDashboard}
|
||||
dataProvider={portalAdminDataProvider}
|
||||
disableTelemetry
|
||||
requireAuth
|
||||
title="YOYUZH Admin"
|
||||
>
|
||||
<Resource
|
||||
name="users"
|
||||
icon={GroupsOutlined}
|
||||
list={PortalAdminUsersList}
|
||||
options={{ label: '用户资源' }}
|
||||
recordRepresentation="username"
|
||||
/>
|
||||
<Resource
|
||||
name="files"
|
||||
icon={FolderOutlined}
|
||||
list={PortalAdminFilesList}
|
||||
options={{ label: '文件资源' }}
|
||||
recordRepresentation="filename"
|
||||
/>
|
||||
<Resource
|
||||
name="schoolSnapshots"
|
||||
icon={SchoolOutlined}
|
||||
list={PortalAdminSchoolSnapshotsList}
|
||||
options={{ label: '教务缓存' }}
|
||||
recordRepresentation="username"
|
||||
/>
|
||||
</Admin>
|
||||
);
|
||||
}
|
||||
38
front/src/admin/auth-provider.test.ts
Normal file
38
front/src/admin/auth-provider.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AuthSession } from '@/src/lib/types';
|
||||
|
||||
import { buildAdminIdentity, hasAdminSession, portalAdminAuthProvider } from './auth-provider';
|
||||
|
||||
const session: AuthSession = {
|
||||
token: 'token-123',
|
||||
refreshToken: 'refresh-123',
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'alice',
|
||||
email: 'alice@example.com',
|
||||
createdAt: '2026-03-19T15:00:00',
|
||||
},
|
||||
};
|
||||
|
||||
test('hasAdminSession returns true only when a token is present', () => {
|
||||
assert.equal(hasAdminSession(session), true);
|
||||
assert.equal(hasAdminSession({...session, token: ''}), false);
|
||||
assert.equal(hasAdminSession(null), false);
|
||||
});
|
||||
|
||||
test('buildAdminIdentity maps the portal session user to react-admin identity', () => {
|
||||
assert.deepEqual(buildAdminIdentity(session), {
|
||||
id: '7',
|
||||
fullName: 'alice',
|
||||
});
|
||||
});
|
||||
|
||||
test('checkError keeps the session when admin API returns 403', async () => {
|
||||
await assert.doesNotReject(() => portalAdminAuthProvider.checkError?.({status: 403}));
|
||||
});
|
||||
|
||||
test('checkError rejects when admin API returns 401', async () => {
|
||||
await assert.rejects(() => portalAdminAuthProvider.checkError?.({status: 401}));
|
||||
});
|
||||
50
front/src/admin/auth-provider.ts
Normal file
50
front/src/admin/auth-provider.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AuthProvider, UserIdentity } from 'react-admin';
|
||||
|
||||
import { clearStoredSession, readStoredSession } from '@/src/lib/session';
|
||||
import type { AuthSession } from '@/src/lib/types';
|
||||
|
||||
export function hasAdminSession(session: AuthSession | null | undefined) {
|
||||
return Boolean(session?.token?.trim());
|
||||
}
|
||||
|
||||
export function buildAdminIdentity(session: AuthSession): UserIdentity {
|
||||
return {
|
||||
id: String(session.user.id),
|
||||
fullName: session.user.username,
|
||||
};
|
||||
}
|
||||
|
||||
export const portalAdminAuthProvider: AuthProvider = {
|
||||
login: async () => {
|
||||
throw new Error('请先使用门户登录页完成登录');
|
||||
},
|
||||
logout: async () => {
|
||||
clearStoredSession();
|
||||
return '/login';
|
||||
},
|
||||
checkAuth: async () => {
|
||||
if (!hasAdminSession(readStoredSession())) {
|
||||
throw new Error('当前没有可用登录状态');
|
||||
}
|
||||
},
|
||||
checkError: async (error) => {
|
||||
const status = error?.status;
|
||||
if (status === 401) {
|
||||
clearStoredSession();
|
||||
throw new Error('登录状态已失效');
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
getIdentity: async () => {
|
||||
const session = readStoredSession();
|
||||
if (!session) {
|
||||
throw new Error('当前没有可用登录状态');
|
||||
}
|
||||
|
||||
return buildAdminIdentity(session);
|
||||
},
|
||||
getPermissions: async () => [],
|
||||
};
|
||||
160
front/src/admin/dashboard.tsx
Normal file
160
front/src/admin/dashboard.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Card, CardContent, Chip, CircularProgress, Grid, Stack, Typography } from '@mui/material';
|
||||
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { readStoredSession } from '@/src/lib/session';
|
||||
import type { AdminSummary } from '@/src/lib/types';
|
||||
|
||||
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: '已接入 /api/admin/school-snapshots,可查看最近学号、学期和缓存条数。',
|
||||
status: 'connected',
|
||||
},
|
||||
];
|
||||
|
||||
export function PortalAdminDashboard() {
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
summary: null,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const session = readStoredSession();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function loadDashboardData() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDashboardData();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack spacing={3} sx={{ p: 2 }}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h4" fontWeight={700}>
|
||||
YOYUZH Admin
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
这是嵌入现有门户应用的 react-admin 管理入口,当前通过 `/api/admin/**` 提供后台数据。
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{loading && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<CircularProgress size={20} />
|
||||
<Typography color="text.secondary">正在加载后台数据...</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && <Alert severity="error">{error}</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>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
当前管理员
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
用户名:{session?.user.username ?? '-'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
邮箱:{session?.user.email ?? '-'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
用户 ID:{session?.user.id ?? '-'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<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>
|
||||
<Typography color="text.secondary">
|
||||
有教务缓存的用户:{state.summary?.usersWithSchoolCache ?? 0}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
105
front/src/admin/data-provider.test.ts
Normal file
105
front/src/admin/data-provider.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AdminFile, PageResponse } from '@/src/lib/types';
|
||||
|
||||
import {
|
||||
buildAdminListPath,
|
||||
buildFilesListPath,
|
||||
mapFilesListResponse,
|
||||
} from './data-provider';
|
||||
|
||||
test('buildFilesListPath maps react-admin pagination to the backend files list query', () => {
|
||||
assert.equal(
|
||||
buildFilesListPath({
|
||||
pagination: {
|
||||
page: 3,
|
||||
perPage: 25,
|
||||
},
|
||||
filter: {},
|
||||
}),
|
||||
'/admin/files?page=2&size=25',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildFilesListPath includes file and owner search filters when present', () => {
|
||||
assert.equal(
|
||||
buildFilesListPath({
|
||||
pagination: {
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
},
|
||||
filter: {
|
||||
query: 'report',
|
||||
ownerQuery: 'alice',
|
||||
},
|
||||
}),
|
||||
'/admin/files?page=0&size=25&query=report&ownerQuery=alice',
|
||||
);
|
||||
});
|
||||
|
||||
test('mapFilesListResponse preserves list items and total count', () => {
|
||||
const payload: PageResponse<AdminFile> = {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'hello.txt',
|
||||
path: '/',
|
||||
size: 12,
|
||||
contentType: 'text/plain',
|
||||
directory: false,
|
||||
createdAt: '2026-03-19T15:00:00',
|
||||
ownerId: 7,
|
||||
ownerUsername: 'alice',
|
||||
ownerEmail: 'alice@example.com',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 25,
|
||||
};
|
||||
|
||||
assert.deepEqual(mapFilesListResponse(payload), {
|
||||
data: payload.items,
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildAdminListPath maps generic admin resources to backend paging queries', () => {
|
||||
assert.equal(
|
||||
buildAdminListPath('users', {
|
||||
pagination: {
|
||||
page: 2,
|
||||
perPage: 20,
|
||||
},
|
||||
filter: {},
|
||||
}),
|
||||
'/admin/users?page=1&size=20',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
buildAdminListPath('schoolSnapshots', {
|
||||
pagination: {
|
||||
page: 1,
|
||||
perPage: 50,
|
||||
},
|
||||
filter: {},
|
||||
}),
|
||||
'/admin/school-snapshots?page=0&size=50',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildAdminListPath includes the user search query when present', () => {
|
||||
assert.equal(
|
||||
buildAdminListPath('users', {
|
||||
pagination: {
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
},
|
||||
filter: {
|
||||
query: 'alice',
|
||||
},
|
||||
}),
|
||||
'/admin/users?page=0&size=25&query=alice',
|
||||
);
|
||||
});
|
||||
144
front/src/admin/data-provider.ts
Normal file
144
front/src/admin/data-provider.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { DataProvider, GetListParams, GetListResult, Identifier } from 'react-admin';
|
||||
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import type {
|
||||
AdminFile,
|
||||
AdminSchoolSnapshot,
|
||||
AdminUser,
|
||||
PageResponse,
|
||||
} from '@/src/lib/types';
|
||||
|
||||
const FILES_RESOURCE = 'files';
|
||||
const USERS_RESOURCE = 'users';
|
||||
const SCHOOL_SNAPSHOTS_RESOURCE = 'schoolSnapshots';
|
||||
|
||||
function createUnsupportedError(resource: string, action: string) {
|
||||
return new Error(`当前管理台暂未为资源 "${resource}" 实现 ${action} 操作`);
|
||||
}
|
||||
|
||||
function ensureSupportedResource(resource: string, action: string) {
|
||||
if (![FILES_RESOURCE, USERS_RESOURCE, SCHOOL_SNAPSHOTS_RESOURCE].includes(resource)) {
|
||||
throw createUnsupportedError(resource, action);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFilterValue(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function buildAdminListPath(resource: string, params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
||||
const page = Math.max(0, params.pagination.page - 1);
|
||||
const size = Math.max(1, params.pagination.perPage);
|
||||
const query = normalizeFilterValue(params.filter?.query);
|
||||
|
||||
if (resource === USERS_RESOURCE) {
|
||||
return `/admin/users?page=${page}&size=${size}${query ? `&query=${encodeURIComponent(query)}` : ''}`;
|
||||
}
|
||||
|
||||
if (resource === SCHOOL_SNAPSHOTS_RESOURCE) {
|
||||
return `/admin/school-snapshots?page=${page}&size=${size}`;
|
||||
}
|
||||
|
||||
throw createUnsupportedError(resource, 'list');
|
||||
}
|
||||
|
||||
export function buildFilesListPath(params: Pick<GetListParams, 'pagination' | 'filter'>) {
|
||||
const page = Math.max(0, params.pagination.page - 1);
|
||||
const size = Math.max(1, params.pagination.perPage);
|
||||
const query = normalizeFilterValue(params.filter?.query);
|
||||
const ownerQuery = normalizeFilterValue(params.filter?.ownerQuery);
|
||||
const search = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
if (query) {
|
||||
search.set('query', query);
|
||||
}
|
||||
if (ownerQuery) {
|
||||
search.set('ownerQuery', ownerQuery);
|
||||
}
|
||||
return `/admin/files?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function mapFilesListResponse(
|
||||
payload: PageResponse<AdminFile>,
|
||||
): GetListResult<AdminFile> {
|
||||
return {
|
||||
data: payload.items,
|
||||
total: payload.total,
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFile(id: Identifier) {
|
||||
await apiRequest(`/admin/files/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export const portalAdminDataProvider: DataProvider = {
|
||||
getList: async (resource, params) => {
|
||||
ensureSupportedResource(resource, 'list');
|
||||
|
||||
if (resource === FILES_RESOURCE) {
|
||||
const payload = await apiRequest<PageResponse<AdminFile>>(buildFilesListPath(params));
|
||||
return mapFilesListResponse(payload) as GetListResult;
|
||||
}
|
||||
|
||||
if (resource === USERS_RESOURCE) {
|
||||
const payload = await apiRequest<PageResponse<AdminUser>>(buildAdminListPath(resource, params));
|
||||
return {
|
||||
data: payload.items,
|
||||
total: payload.total,
|
||||
} as GetListResult;
|
||||
}
|
||||
|
||||
const payload = await apiRequest<PageResponse<AdminSchoolSnapshot>>(buildAdminListPath(resource, params));
|
||||
return {
|
||||
data: payload.items,
|
||||
total: payload.total,
|
||||
} as GetListResult;
|
||||
},
|
||||
getOne: async (resource) => {
|
||||
ensureSupportedResource(resource, 'getOne');
|
||||
throw createUnsupportedError(resource, 'getOne');
|
||||
},
|
||||
getMany: async (resource) => {
|
||||
ensureSupportedResource(resource, 'getMany');
|
||||
throw createUnsupportedError(resource, 'getMany');
|
||||
},
|
||||
getManyReference: async (resource) => {
|
||||
ensureSupportedResource(resource, 'getManyReference');
|
||||
throw createUnsupportedError(resource, 'getManyReference');
|
||||
},
|
||||
update: async (resource) => {
|
||||
ensureSupportedResource(resource, 'update');
|
||||
throw createUnsupportedError(resource, 'update');
|
||||
},
|
||||
updateMany: async (resource) => {
|
||||
ensureSupportedResource(resource, 'updateMany');
|
||||
throw createUnsupportedError(resource, 'updateMany');
|
||||
},
|
||||
create: async (resource) => {
|
||||
ensureSupportedResource(resource, 'create');
|
||||
throw createUnsupportedError(resource, 'create');
|
||||
},
|
||||
delete: async (resource, params) => {
|
||||
if (resource !== FILES_RESOURCE) {
|
||||
throw createUnsupportedError(resource, 'delete');
|
||||
}
|
||||
await deleteFile(params.id);
|
||||
const fallbackRecord = { id: params.id } as typeof params.previousData;
|
||||
return {
|
||||
data: (params.previousData ?? fallbackRecord) as typeof params.previousData,
|
||||
};
|
||||
},
|
||||
deleteMany: async (resource, params) => {
|
||||
if (resource !== FILES_RESOURCE) {
|
||||
throw createUnsupportedError(resource, 'deleteMany');
|
||||
}
|
||||
await Promise.all(params.ids.map((id) => deleteFile(id)));
|
||||
return {
|
||||
data: params.ids,
|
||||
};
|
||||
},
|
||||
};
|
||||
69
front/src/admin/files-list.tsx
Normal file
69
front/src/admin/files-list.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Chip } from '@mui/material';
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
DeleteWithConfirmButton,
|
||||
FunctionField,
|
||||
List,
|
||||
RefreshButton,
|
||||
SearchInput,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
} from 'react-admin';
|
||||
|
||||
import type { AdminFile } from '@/src/lib/types';
|
||||
|
||||
function FilesListActions() {
|
||||
return (
|
||||
<TopToolbar>
|
||||
<RefreshButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
if (size >= 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
if (size >= 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${size} B`;
|
||||
}
|
||||
|
||||
export function PortalAdminFilesList() {
|
||||
return (
|
||||
<List
|
||||
actions={<FilesListActions />}
|
||||
filters={[
|
||||
<SearchInput key="query" source="query" alwaysOn placeholder="搜索文件名或路径" />,
|
||||
<SearchInput key="ownerQuery" source="ownerQuery" placeholder="搜索所属用户" />,
|
||||
]}
|
||||
perPage={25}
|
||||
resource="files"
|
||||
title="文件管理"
|
||||
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||
>
|
||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||
<TextField source="id" label="ID" />
|
||||
<TextField source="filename" label="文件名" />
|
||||
<TextField source="path" label="路径" />
|
||||
<TextField source="ownerUsername" label="所属用户" />
|
||||
<TextField source="ownerEmail" label="用户邮箱" />
|
||||
<FunctionField<AdminFile>
|
||||
label="类型"
|
||||
render={(record) =>
|
||||
record.directory ? <Chip label="目录" size="small" /> : <Chip label="文件" size="small" variant="outlined" />
|
||||
}
|
||||
/>
|
||||
<FunctionField<AdminFile>
|
||||
label="大小"
|
||||
render={(record) => (record.directory ? '-' : formatFileSize(record.size))}
|
||||
/>
|
||||
<TextField source="contentType" label="Content-Type" emptyText="-" />
|
||||
<DateField source="createdAt" label="创建时间" showTime />
|
||||
<DeleteWithConfirmButton mutationMode="pessimistic" label="删除" confirmTitle="删除文件" confirmContent="确认删除该文件吗?" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
22
front/src/admin/school-snapshots-list.tsx
Normal file
22
front/src/admin/school-snapshots-list.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Datagrid, List, NumberField, TextField } from 'react-admin';
|
||||
|
||||
export function PortalAdminSchoolSnapshotsList() {
|
||||
return (
|
||||
<List
|
||||
perPage={25}
|
||||
resource="schoolSnapshots"
|
||||
title="教务缓存"
|
||||
sort={{ field: 'id', order: 'DESC' }}
|
||||
>
|
||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||
<TextField source="userId" label="用户 ID" />
|
||||
<TextField source="username" label="用户名" />
|
||||
<TextField source="email" label="邮箱" />
|
||||
<TextField source="studentId" label="学号" emptyText="-" />
|
||||
<TextField source="semester" label="学期" emptyText="-" />
|
||||
<NumberField source="scheduleCount" label="课表数" />
|
||||
<NumberField source="gradeCount" label="成绩数" />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
186
front/src/admin/users-list.tsx
Normal file
186
front/src/admin/users-list.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Chip, Stack } from '@mui/material';
|
||||
import {
|
||||
Datagrid,
|
||||
DateField,
|
||||
FunctionField,
|
||||
List,
|
||||
SearchInput,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
RefreshButton,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
} from 'react-admin';
|
||||
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import type { AdminPasswordResetResponse, AdminUser, AdminUserRole } from '@/src/lib/types';
|
||||
|
||||
const USER_ROLE_OPTIONS: AdminUserRole[] = ['USER', 'MODERATOR', 'ADMIN'];
|
||||
|
||||
function UsersListActions() {
|
||||
return (
|
||||
<TopToolbar>
|
||||
<RefreshButton />
|
||||
</TopToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminUserActions({ record }: { record: AdminUser }) {
|
||||
const notify = useNotify();
|
||||
const refresh = useRefresh();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleRoleAssign() {
|
||||
const input = window.prompt('请输入角色:USER / MODERATOR / ADMIN', record.role);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const role = input.trim().toUpperCase() as AdminUserRole;
|
||||
if (!USER_ROLE_OPTIONS.includes(role)) {
|
||||
notify('角色必须是 USER、MODERATOR 或 ADMIN', { type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiRequest(`/admin/users/${record.id}/role`, {
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
});
|
||||
notify(`已将 ${record.username} 设为 ${role}`, { type: 'success' });
|
||||
refresh();
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '角色更新失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleBan() {
|
||||
const nextBanned = !record.banned;
|
||||
const confirmed = window.confirm(
|
||||
nextBanned ? `确认封禁用户 ${record.username} 吗?` : `确认解封用户 ${record.username} 吗?`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiRequest(`/admin/users/${record.id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: { banned: nextBanned },
|
||||
});
|
||||
notify(nextBanned ? '用户已封禁' : '用户已解封', { type: 'success' });
|
||||
refresh();
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '状态更新失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetPassword() {
|
||||
const newPassword = window.prompt(
|
||||
'请输入新密码。密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符。',
|
||||
);
|
||||
if (!newPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiRequest(`/admin/users/${record.id}/password`, {
|
||||
method: 'PUT',
|
||||
body: { newPassword },
|
||||
});
|
||||
notify('密码已更新,旧 refresh token 已失效', { type: 'success' });
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '密码更新失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
const confirmed = window.confirm(`确认重置 ${record.username} 的密码吗?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await apiRequest<AdminPasswordResetResponse>(`/admin/users/${record.id}/password/reset`, {
|
||||
method: 'POST',
|
||||
});
|
||||
notify('已生成临时密码,请立即复制并安全发送给用户', { type: 'success' });
|
||||
window.prompt(`用户 ${record.username} 的临时密码如下,请复制保存`, result.temporaryPassword);
|
||||
} catch (error) {
|
||||
notify(error instanceof Error ? error.message : '密码重置失败', { type: 'error' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleRoleAssign()}>
|
||||
角色分配
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleSetPassword()}>
|
||||
修改密码
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" disabled={busy} onClick={() => void handleResetPassword()}>
|
||||
重置密码
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={record.banned ? 'contained' : 'outlined'}
|
||||
color={record.banned ? 'success' : 'warning'}
|
||||
disabled={busy}
|
||||
onClick={() => void handleToggleBan()}
|
||||
>
|
||||
{record.banned ? '解封' : '封禁'}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortalAdminUsersList() {
|
||||
return (
|
||||
<List
|
||||
actions={<UsersListActions />}
|
||||
filters={[<SearchInput key="query" source="query" alwaysOn placeholder="搜索用户名或邮箱" />]}
|
||||
perPage={25}
|
||||
resource="users"
|
||||
title="用户管理"
|
||||
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||
>
|
||||
<Datagrid bulkActionButtons={false} rowClick={false}>
|
||||
<TextField source="id" label="ID" />
|
||||
<TextField source="username" label="用户名" />
|
||||
<TextField source="email" label="邮箱" />
|
||||
<FunctionField<AdminUser>
|
||||
label="角色"
|
||||
render={(record) => <Chip label={record.role} size="small" color={record.role === 'ADMIN' ? 'primary' : 'default'} />}
|
||||
/>
|
||||
<FunctionField<AdminUser>
|
||||
label="状态"
|
||||
render={(record) => (
|
||||
<Chip
|
||||
label={record.banned ? '已封禁' : '正常'}
|
||||
size="small"
|
||||
color={record.banned ? 'warning' : 'success'}
|
||||
variant={record.banned ? 'filled' : 'outlined'}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextField source="lastSchoolStudentId" label="最近学号" emptyText="-" />
|
||||
<TextField source="lastSchoolSemester" label="最近学期" emptyText="-" />
|
||||
<DateField source="createdAt" label="创建时间" showTime />
|
||||
<FunctionField<AdminUser> label="操作" render={(record) => <AdminUserActions record={record} />} />
|
||||
</Datagrid>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { apiRequest } from '@/src/lib/api';
|
||||
import { fetchAdminAccessStatus } from './admin-access';
|
||||
import {
|
||||
clearStoredSession,
|
||||
createSession,
|
||||
@@ -19,6 +20,7 @@ interface AuthContextValue {
|
||||
ready: boolean;
|
||||
session: AuthSession | null;
|
||||
user: UserProfile | null;
|
||||
isAdmin: boolean;
|
||||
login: (payload: LoginPayload) => Promise<void>;
|
||||
devLogin: (username?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -34,6 +36,7 @@ function buildSession(auth: AuthResponse): AuthSession {
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<AuthSession | null>(() => readStoredSession());
|
||||
const [ready, setReady] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const syncSession = () => {
|
||||
@@ -93,6 +96,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function syncAdminAccess() {
|
||||
if (!session?.token) {
|
||||
if (active) {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allowed = await fetchAdminAccessStatus();
|
||||
if (active) {
|
||||
setIsAdmin(allowed);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncAdminAccess();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [session?.token]);
|
||||
|
||||
async function refreshProfile() {
|
||||
const currentSession = readStoredSession();
|
||||
if (!currentSession) {
|
||||
@@ -146,6 +179,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
ready,
|
||||
session,
|
||||
user: session?.user || null,
|
||||
isAdmin,
|
||||
login,
|
||||
devLogin,
|
||||
logout,
|
||||
|
||||
28
front/src/auth/admin-access.test.ts
Normal file
28
front/src/auth/admin-access.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ApiError } from '@/src/lib/api';
|
||||
|
||||
import { fetchAdminAccessStatus } from './admin-access';
|
||||
|
||||
test('fetchAdminAccessStatus returns true when the admin summary request succeeds', async () => {
|
||||
const request = async () => ({
|
||||
totalUsers: 1,
|
||||
totalFiles: 2,
|
||||
usersWithSchoolCache: 3,
|
||||
});
|
||||
|
||||
await assert.doesNotReject(async () => {
|
||||
const allowed = await fetchAdminAccessStatus(request);
|
||||
assert.equal(allowed, true);
|
||||
});
|
||||
});
|
||||
|
||||
test('fetchAdminAccessStatus returns false when the server rejects the user with 403', async () => {
|
||||
const request = async () => {
|
||||
throw new ApiError('没有后台权限', 403);
|
||||
};
|
||||
|
||||
const allowed = await fetchAdminAccessStatus(request);
|
||||
assert.equal(allowed, false);
|
||||
});
|
||||
19
front/src/auth/admin-access.ts
Normal file
19
front/src/auth/admin-access.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ApiError, apiRequest } from '@/src/lib/api';
|
||||
import type { AdminSummary } from '@/src/lib/types';
|
||||
|
||||
type AdminSummaryRequest = () => Promise<AdminSummary>;
|
||||
|
||||
export async function fetchAdminAccessStatus(
|
||||
request: AdminSummaryRequest = () => apiRequest<AdminSummary>('/admin/summary'),
|
||||
) {
|
||||
try {
|
||||
await request();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 403) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
12
front/src/components/layout/Layout.test.ts
Normal file
12
front/src/components/layout/Layout.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { getVisibleNavItems } from './Layout';
|
||||
|
||||
test('getVisibleNavItems hides the admin entry for non-admin users', () => {
|
||||
assert.equal(getVisibleNavItems(false).some((item) => item.path === '/admin'), false);
|
||||
});
|
||||
|
||||
test('getVisibleNavItems keeps the admin entry for admin users', () => {
|
||||
assert.equal(getVisibleNavItems(true).some((item) => item.path === '/admin'), true);
|
||||
});
|
||||
@@ -1,38 +1,333 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react';
|
||||
import {
|
||||
Gamepad2,
|
||||
FolderOpen,
|
||||
GraduationCap,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Mail,
|
||||
Settings,
|
||||
Shield,
|
||||
Smartphone,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
import { clearStoredSession } from '@/src/lib/session';
|
||||
import { useAuth } from '@/src/auth/AuthProvider';
|
||||
import { apiBinaryUploadRequest, apiDownload, apiRequest, apiUploadRequest } from '@/src/lib/api';
|
||||
import { createSession, readStoredSession, saveStoredSession } from '@/src/lib/session';
|
||||
import type { AuthResponse, InitiateUploadResponse, UserProfile } from '@/src/lib/types';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { Button } from '@/src/components/ui/button';
|
||||
import { Input } from '@/src/components/ui/input';
|
||||
|
||||
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ name: '总览', path: '/overview', icon: LayoutDashboard },
|
||||
{ name: '网盘', path: '/files', icon: FolderOpen },
|
||||
{ name: '教务', path: '/school', icon: GraduationCap },
|
||||
{ name: '游戏', path: '/games', icon: Gamepad2 },
|
||||
];
|
||||
{ name: '后台', path: '/admin', icon: Shield },
|
||||
] as const;
|
||||
|
||||
type ActiveModal = 'security' | 'settings' | null;
|
||||
|
||||
export function getVisibleNavItems(isAdmin: boolean) {
|
||||
return NAV_ITEMS.filter((item) => isAdmin || item.path !== '/admin');
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, logout, refreshProfile, user } = useAuth();
|
||||
const navItems = getVisibleNavItems(isAdmin);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(null);
|
||||
const [selectedAvatarFile, setSelectedAvatarFile] = useState<File | null>(null);
|
||||
const [avatarSourceUrl, setAvatarSourceUrl] = useState<string | null>(user?.avatarUrl ?? null);
|
||||
const [profileDraft, setProfileDraft] = useState(() =>
|
||||
buildAccountDraft(
|
||||
user ?? {
|
||||
id: 0,
|
||||
username: '',
|
||||
email: '',
|
||||
createdAt: '',
|
||||
},
|
||||
),
|
||||
);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [profileMessage, setProfileMessage] = useState('');
|
||||
const [passwordMessage, setPasswordMessage] = useState('');
|
||||
const [profileError, setProfileError] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [profileSubmitting, setProfileSubmitting] = useState(false);
|
||||
const [passwordSubmitting, setPasswordSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
setProfileDraft(buildAccountDraft(user));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarPreviewUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(avatarPreviewUrl);
|
||||
};
|
||||
}, [avatarPreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
async function syncAvatar() {
|
||||
if (!user?.avatarUrl) {
|
||||
if (active) {
|
||||
setAvatarSourceUrl(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldLoadAvatarWithAuth(user.avatarUrl)) {
|
||||
if (active) {
|
||||
setAvatarSourceUrl(user.avatarUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiDownload(user.avatarUrl);
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (active) {
|
||||
setAvatarSourceUrl(objectUrl);
|
||||
}
|
||||
} catch {
|
||||
if (active) {
|
||||
setAvatarSourceUrl(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void syncAvatar();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [user?.avatarUrl]);
|
||||
|
||||
const displayName = useMemo(() => {
|
||||
if (!user) {
|
||||
return '账户';
|
||||
}
|
||||
return user.displayName || user.username;
|
||||
}, [user]);
|
||||
|
||||
const email = user?.email || '暂无邮箱';
|
||||
const roleLabel = getRoleLabel(user?.role);
|
||||
const avatarFallback = (displayName || 'Y').charAt(0).toUpperCase();
|
||||
const displayedAvatarUrl = avatarPreviewUrl || avatarSourceUrl;
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStoredSession();
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAvatarFile(file);
|
||||
setAvatarPreviewUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleProfileDraftChange = (field: keyof typeof profileDraft, value: string) => {
|
||||
setProfileDraft((current) => ({
|
||||
...current,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveModal(null);
|
||||
setProfileMessage('');
|
||||
setProfileError('');
|
||||
setPasswordMessage('');
|
||||
setPasswordError('');
|
||||
};
|
||||
|
||||
const persistSessionUser = (nextProfile: UserProfile) => {
|
||||
const currentSession = readStoredSession();
|
||||
if (!currentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveStoredSession({
|
||||
...currentSession,
|
||||
user: nextProfile,
|
||||
});
|
||||
};
|
||||
|
||||
const uploadAvatar = async (file: File) => {
|
||||
const initiated = await apiRequest<InitiateUploadResponse>('/user/avatar/upload/initiate', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
filename: file.name,
|
||||
contentType: file.type || 'image/png',
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
|
||||
if (initiated.direct) {
|
||||
try {
|
||||
await apiBinaryUploadRequest(initiated.uploadUrl, {
|
||||
method: initiated.method,
|
||||
headers: initiated.headers,
|
||||
body: file,
|
||||
});
|
||||
} catch {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await apiUploadRequest<void>(`/user/avatar/upload?storageName=${encodeURIComponent(initiated.storageName)}`, {
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
await apiUploadRequest<void>(initiated.uploadUrl, {
|
||||
body: formData,
|
||||
method: initiated.method === 'PUT' ? 'PUT' : 'POST',
|
||||
headers: initiated.headers,
|
||||
});
|
||||
}
|
||||
|
||||
const nextProfile = await apiRequest<UserProfile>('/user/avatar/upload/complete', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
filename: file.name,
|
||||
contentType: file.type || 'image/png',
|
||||
size: file.size,
|
||||
storageName: initiated.storageName,
|
||||
},
|
||||
});
|
||||
|
||||
persistSessionUser(nextProfile);
|
||||
return nextProfile;
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setProfileSubmitting(true);
|
||||
setProfileMessage('');
|
||||
setProfileError('');
|
||||
|
||||
try {
|
||||
if (selectedAvatarFile) {
|
||||
await uploadAvatar(selectedAvatarFile);
|
||||
}
|
||||
|
||||
const nextProfile = await apiRequest<UserProfile>('/user/profile', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
displayName: profileDraft.displayName.trim(),
|
||||
email: profileDraft.email.trim(),
|
||||
bio: profileDraft.bio,
|
||||
preferredLanguage: profileDraft.preferredLanguage,
|
||||
},
|
||||
});
|
||||
|
||||
persistSessionUser(nextProfile);
|
||||
|
||||
await refreshProfile();
|
||||
setSelectedAvatarFile(null);
|
||||
setAvatarPreviewUrl((current) => {
|
||||
if (current) {
|
||||
URL.revokeObjectURL(current);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
setProfileMessage('账户资料已保存');
|
||||
} catch (error) {
|
||||
setProfileError(error instanceof Error ? error.message : '账户资料保存失败');
|
||||
} finally {
|
||||
setProfileSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
setPasswordMessage('');
|
||||
setPasswordError('');
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordSubmitting(true);
|
||||
try {
|
||||
const auth = await apiRequest<AuthResponse>('/user/password', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
const currentSession = readStoredSession();
|
||||
if (currentSession) {
|
||||
saveStoredSession({
|
||||
...currentSession,
|
||||
...createSession(auth),
|
||||
user: auth.user,
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordMessage('密码已更新,当前登录态已同步刷新');
|
||||
} catch (error) {
|
||||
setPasswordError(error instanceof Error ? error.message : '密码修改失败');
|
||||
} finally {
|
||||
setPasswordSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
|
||||
{/* Animated Gradient Background */}
|
||||
<div className="fixed inset-0 z-0 pointer-events-none">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen filter blur-[120px] animate-blob" />
|
||||
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-2000" />
|
||||
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-4000" />
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen blur-[120px] animate-blob" />
|
||||
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-2000" />
|
||||
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen blur-[120px] animate-blob animation-delay-4000" />
|
||||
</div>
|
||||
|
||||
{/* Top Navigation */}
|
||||
<header className="fixed inset-x-0 top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
|
||||
<header className="sticky top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
|
||||
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||
{/* Brand */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
|
||||
<span className="text-white font-bold text-lg leading-none">Y</span>
|
||||
@@ -43,26 +338,21 @@ export function Layout() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav Links */}
|
||||
<nav className="hidden md:flex items-center gap-2">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
|
||||
isActive
|
||||
? 'text-white shadow-md shadow-[#336EFF]/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
isActive ? 'text-white shadow-md shadow-[#336EFF]/20' : 'text-slate-400 hover:text-white hover:bg-white/5',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />
|
||||
)}
|
||||
{isActive && <div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />}
|
||||
<item.icon className="w-4 h-4 relative z-10" />
|
||||
<span className="relative z-10">{item.name}</span>
|
||||
</>
|
||||
@@ -71,23 +361,269 @@ export function Layout() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User / Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 relative">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-slate-400 hover:text-white transition-colors p-2 rounded-xl hover:bg-white/5 relative z-10"
|
||||
aria-label="Logout"
|
||||
onClick={() => setIsDropdownOpen((current) => !current)}
|
||||
className="w-10 h-10 rounded-full bg-slate-800 border border-white/10 flex items-center justify-center text-slate-300 hover:text-white hover:border-white/20 transition-all relative z-10 overflow-hidden"
|
||||
aria-label="Account"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
{displayedAvatarUrl ? (
|
||||
<img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{avatarFallback}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsDropdownOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 bg-[#0f172a] border border-white/10 rounded-xl shadow-2xl z-50 py-2 overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-white/10 mb-2">
|
||||
<p className="text-sm font-medium text-white">{displayName}</p>
|
||||
<p className="text-xs text-slate-400 truncate">{email}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveModal('security');
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
|
||||
>
|
||||
<Shield className="w-4 h-4" /> 安全中心
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveModal('settings');
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-slate-300 hover:bg-white/10 hover:text-white flex items-center gap-3 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" /> 账户设置
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-white/10 my-2" />
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 flex items-center gap-3 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" /> 退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative z-10 flex-1 container mx-auto px-4 pb-8 pt-24">
|
||||
<main className="flex-1 container mx-auto px-4 py-8 relative z-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<AnimatePresence>
|
||||
{activeModal === 'security' && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
安全中心
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-xl bg-white/5 border border-white/10 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center">
|
||||
<Key className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">登录密码</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">密码修改后会刷新当前登录凭据并使旧 refresh token 失效</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="当前密码"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="新密码"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
className="bg-black/20 border-white/10"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" disabled={passwordSubmitting} onClick={() => void handleChangePassword()}>
|
||||
{passwordSubmitting ? '保存中...' : '修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Smartphone className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">手机绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前项目暂未实现短信绑定流程</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" disabled className="border-white/10 text-slate-500">
|
||||
暂未开放
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
||||
<Mail className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">邮箱绑定</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">当前邮箱:{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-white/10 hover:bg-white/10 text-slate-300"
|
||||
onClick={() => setActiveModal('settings')}
|
||||
>
|
||||
更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordError && <p className="text-sm text-rose-300">{passwordError}</p>}
|
||||
{passwordMessage && <p className="text-sm text-emerald-300">{passwordMessage}</p>}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModal === 'settings' && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="bg-[#0f172a] border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div className="p-5 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-[#336EFF]" />
|
||||
账户设置
|
||||
</h3>
|
||||
<button onClick={closeModal} className="text-slate-400 hover:text-white transition-colors p-1 rounded-md hover:bg-white/10">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
<div className="flex items-center gap-6 pb-6 border-b border-white/10">
|
||||
<div className="relative group cursor-pointer" onClick={handleAvatarClick}>
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||
{displayedAvatarUrl ? <img src={displayedAvatarUrl} alt="Avatar" className="w-full h-full object-cover" /> : avatarFallback}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/50 rounded-full opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity">
|
||||
<span className="text-xs text-white">{selectedAvatarFile ? '等待保存' : '更换头像'}</span>
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h4 className="text-lg font-medium text-white">{displayName}</h4>
|
||||
<p className="text-sm text-slate-400">{roleLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">昵称</label>
|
||||
<Input
|
||||
value={profileDraft.displayName}
|
||||
onChange={(event) => handleProfileDraftChange('displayName', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">邮箱</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileDraft.email}
|
||||
onChange={(event) => handleProfileDraftChange('email', event.target.value)}
|
||||
className="bg-black/20 border-white/10 text-white focus-visible:ring-[#336EFF]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">个人简介</label>
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded-md bg-black/20 border border-white/10 text-white p-3 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] resize-none"
|
||||
value={profileDraft.bio}
|
||||
onChange={(event) => handleProfileDraftChange('bio', event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">语言偏好</label>
|
||||
<select
|
||||
className="w-full rounded-md bg-black/20 border border-white/10 text-white p-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#336EFF] appearance-none"
|
||||
value={profileDraft.preferredLanguage}
|
||||
onChange={(event) => handleProfileDraftChange('preferredLanguage', event.target.value)}
|
||||
>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileError && <p className="text-sm text-rose-300">{profileError}</p>}
|
||||
{profileMessage && <p className="text-sm text-emerald-300">{profileMessage}</p>}
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={closeModal} className="border-white/10 hover:bg-white/10 text-slate-300">
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="default" disabled={profileSubmitting} onClick={() => void handleSaveProfile()}>
|
||||
{profileSubmitting ? '保存中...' : '保存更改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
front/src/components/layout/account-utils.test.ts
Normal file
38
front/src/components/layout/account-utils.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { UserProfile } from '@/src/lib/types';
|
||||
|
||||
import { buildAccountDraft, getRoleLabel, shouldLoadAvatarWithAuth } from './account-utils';
|
||||
|
||||
test('buildAccountDraft prefers display name and fills fallback values', () => {
|
||||
const profile: UserProfile = {
|
||||
id: 1,
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
bio: null,
|
||||
preferredLanguage: null,
|
||||
role: 'USER',
|
||||
createdAt: '2026-03-19T17:00:00',
|
||||
};
|
||||
|
||||
assert.deepEqual(buildAccountDraft(profile), {
|
||||
displayName: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
bio: '',
|
||||
preferredLanguage: 'zh-CN',
|
||||
});
|
||||
});
|
||||
|
||||
test('getRoleLabel maps backend roles to readable chinese labels', () => {
|
||||
assert.equal(getRoleLabel('ADMIN'), '管理员');
|
||||
assert.equal(getRoleLabel('MODERATOR'), '协管员');
|
||||
assert.equal(getRoleLabel('USER'), '普通用户');
|
||||
});
|
||||
|
||||
test('shouldLoadAvatarWithAuth only treats relative avatar urls as protected resources', () => {
|
||||
assert.equal(shouldLoadAvatarWithAuth('/api/user/avatar/content?v=1'), true);
|
||||
assert.equal(shouldLoadAvatarWithAuth('https://cdn.example.com/avatar.png?sig=1'), false);
|
||||
assert.equal(shouldLoadAvatarWithAuth(null), false);
|
||||
});
|
||||
32
front/src/components/layout/account-utils.ts
Normal file
32
front/src/components/layout/account-utils.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { AdminUserRole, UserProfile } from '@/src/lib/types';
|
||||
|
||||
export interface AccountDraft {
|
||||
displayName: string;
|
||||
email: string;
|
||||
bio: string;
|
||||
preferredLanguage: string;
|
||||
}
|
||||
|
||||
export function buildAccountDraft(profile: UserProfile): AccountDraft {
|
||||
return {
|
||||
displayName: profile.displayName || profile.username,
|
||||
email: profile.email,
|
||||
bio: profile.bio || '',
|
||||
preferredLanguage: profile.preferredLanguage || 'zh-CN',
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: AdminUserRole | undefined) {
|
||||
switch (role) {
|
||||
case 'ADMIN':
|
||||
return '管理员';
|
||||
case 'MODERATOR':
|
||||
return '协管员';
|
||||
default:
|
||||
return '普通用户';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldLoadAvatarWithAuth(avatarUrl: string | null | undefined) {
|
||||
return Boolean(avatarUrl && avatarUrl.startsWith('/'));
|
||||
}
|
||||
@@ -1,8 +1,60 @@
|
||||
export interface UserProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
email: string;
|
||||
bio?: string | null;
|
||||
preferredLanguage?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
role?: AdminUserRole;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type AdminUserRole = 'USER' | 'MODERATOR' | 'ADMIN';
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number;
|
||||
totalFiles: number;
|
||||
usersWithSchoolCache: number;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
lastSchoolStudentId: string | null;
|
||||
lastSchoolSemester: string | null;
|
||||
role: AdminUserRole;
|
||||
banned: boolean;
|
||||
}
|
||||
|
||||
export interface AdminFile {
|
||||
id: number;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
contentType: string | null;
|
||||
directory: boolean;
|
||||
createdAt: string;
|
||||
ownerId: number;
|
||||
ownerUsername: string;
|
||||
ownerEmail: string;
|
||||
}
|
||||
|
||||
export interface AdminSchoolSnapshot {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
studentId: string | null;
|
||||
semester: string | null;
|
||||
scheduleCount: number;
|
||||
gradeCount: number;
|
||||
}
|
||||
|
||||
export interface AdminPasswordResetResponse {
|
||||
temporaryPassword: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function Overview() {
|
||||
<Card className="border-amber-400/20 bg-amber-500/10">
|
||||
<CardContent className="flex flex-col gap-3 p-4 text-sm text-amber-100 md:flex-row md:items-center md:justify-between">
|
||||
<span>{loadingError}</span>
|
||||
<Button variant="secondary" size="sm" onClick={() => setRetryToken((value) => value + 1)}>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetryToken((value) => value + 1)}>
|
||||
重新加载总览
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user