添加账号修改,后台管理

This commit is contained in:
yoyuzh
2026-03-19 17:52:58 +08:00
parent c39fde6b19
commit ff8d47f44f
60 changed files with 4264 additions and 58 deletions

View 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>
);
}

View 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}));
});

View 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 () => [],
};

View 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>
);
}

View 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',
);
});

View 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,
};
},
};

View 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>
);
}

View 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>
);
}

View 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>
);
}