feat: refactor agent manager, media library, and player UX
- Split admin users page into player/tier-1/tier-2 tabs with affiliation labels and context-specific create dialogs - Add media library with uploaded_files migration, list/delete unused files API, and admin nav route - Enforce player username format (alphanumeric 3-32) on frontend and backend via shared package - Improve admin dialog/panel styling; refine player parlay and match bet card kickoff display Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { mkdir, writeFile, unlink } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
|
||||
import { ContentService } from '../../domains/operations/content/content.service';
|
||||
@@ -228,11 +228,6 @@ class UpdatePlayerAdminDto {
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
/** 传空字符串表示改为平台直属(无代理) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
@@ -941,6 +936,7 @@ export class AdminController {
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('parentId') parentId?: string,
|
||||
@Query('platformDirect') platformDirect?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const result = await this.users.listPlayers(
|
||||
@@ -949,6 +945,7 @@ export class AdminController {
|
||||
{
|
||||
keyword,
|
||||
parentId: parentId ? BigInt(parentId) : undefined,
|
||||
platformDirect: platformDirect === 'true' || platformDirect === '1',
|
||||
status,
|
||||
},
|
||||
);
|
||||
@@ -1040,12 +1037,22 @@ export class AdminController {
|
||||
@RequirePermissions(P.agentsView)
|
||||
async listAgentOptions() {
|
||||
const agents = await this.prisma.user.findMany({
|
||||
where: { userType: 'AGENT', deletedAt: null, agentLevel: 1 },
|
||||
select: { id: true, username: true },
|
||||
orderBy: { username: 'asc' },
|
||||
where: { userType: 'AGENT', deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
agentLevel: true,
|
||||
parent: { select: { username: true } },
|
||||
},
|
||||
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
|
||||
});
|
||||
return jsonResponse(
|
||||
agents.map((a) => ({ id: a.id.toString(), username: a.username })),
|
||||
agents.map((a) => ({
|
||||
id: a.id.toString(),
|
||||
username: a.username,
|
||||
level: a.agentLevel ?? 1,
|
||||
parentUsername: a.parent?.username ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1055,12 +1062,17 @@ export class AdminController {
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('level') level?: string,
|
||||
@Query('parentAgentId') parentAgentId?: string,
|
||||
) {
|
||||
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
|
||||
const result = await this.agents.listAgentsAdmin({
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
|
||||
keyword,
|
||||
status,
|
||||
level: parsedLevel,
|
||||
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
|
||||
});
|
||||
return jsonResponse(result);
|
||||
@@ -1835,7 +1847,7 @@ export class AdminController {
|
||||
@RequirePermissions(P.content, P.matches)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async uploadAsset(
|
||||
@CurrentUser() user: AdminUploadUser,
|
||||
@CurrentUser() user: AdminUploadUser & { id?: bigint },
|
||||
@UploadedFile() file: UploadedImage | undefined,
|
||||
@Query('category') rawCategory?: string,
|
||||
) {
|
||||
@@ -1849,13 +1861,104 @@ export class AdminController {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(join(targetDir, filename), file.buffer);
|
||||
|
||||
return jsonResponse({
|
||||
category,
|
||||
filename,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
url: `/uploads/${category}/${filename}`,
|
||||
const url = `/uploads/${category}/${filename}`;
|
||||
await this.prisma.uploadedFile.create({
|
||||
data: {
|
||||
filename,
|
||||
category,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
url,
|
||||
uploadedBy: user.id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return jsonResponse({ category, filename, size: file.size, mimeType: file.mimetype, url });
|
||||
}
|
||||
|
||||
@Get('files')
|
||||
@RequirePermissions(P.content, P.matches)
|
||||
async listFiles(
|
||||
@Query('category') category?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
|
||||
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
|
||||
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
|
||||
|
||||
const [files, total] = await Promise.all([
|
||||
this.prisma.uploadedFile.findMany({ where, orderBy: { createdAt: 'desc' }, take, skip }),
|
||||
this.prisma.uploadedFile.count({ where }),
|
||||
]);
|
||||
|
||||
const usedUrls = await this.getUsedFileUrls();
|
||||
const items = files.map((f) => ({ ...f, inUse: usedUrls.has(f.url) }));
|
||||
|
||||
return jsonResponse({ items, total, page: skip / take + 1, pageSize: take });
|
||||
}
|
||||
|
||||
@Delete('files/unused')
|
||||
@RequirePermissions(P.content)
|
||||
async purgeUnusedFiles(@CurrentUser('id') operatorId: bigint) {
|
||||
const all = await this.prisma.uploadedFile.findMany();
|
||||
const usedUrls = await this.getUsedFileUrls();
|
||||
const unused = all.filter((f) => !usedUrls.has(f.url));
|
||||
|
||||
const root = getUploadRoot();
|
||||
let deleted = 0;
|
||||
for (const f of unused) {
|
||||
try {
|
||||
await unlink(join(root, f.category, f.filename));
|
||||
} catch { /* file already missing from disk */ }
|
||||
await this.prisma.uploadedFile.delete({ where: { id: f.id } });
|
||||
deleted++;
|
||||
}
|
||||
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'PURGE_UNUSED_FILES',
|
||||
module: 'MEDIA',
|
||||
afterData: JSON.stringify({ deleted }),
|
||||
});
|
||||
|
||||
return jsonResponse({ deleted });
|
||||
}
|
||||
|
||||
@Delete('files/:id')
|
||||
@RequirePermissions(P.content, P.matches)
|
||||
async deleteFile(
|
||||
@CurrentUser() user: AdminUploadUser,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const record = await this.prisma.uploadedFile.findUnique({ where: { id } });
|
||||
if (!record) throw new BadRequestException('File not found');
|
||||
assertUploadPermission(user, record.category as any);
|
||||
|
||||
const root = getUploadRoot();
|
||||
try {
|
||||
await unlink(join(root, record.category, record.filename));
|
||||
} catch { /* already gone */ }
|
||||
await this.prisma.uploadedFile.delete({ where: { id } });
|
||||
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
private async getUsedFileUrls(): Promise<Set<string>> {
|
||||
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
|
||||
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
|
||||
this.prisma.league.findMany({ select: { logoUrl: true } }),
|
||||
this.prisma.team.findMany({ select: { logoUrl: true } }),
|
||||
this.prisma.userPreference.findMany({ select: { avatarKey: true } }),
|
||||
]);
|
||||
|
||||
const urls = new Set<string>();
|
||||
for (const r of ctRows) if (r.imageUrl) urls.add(r.imageUrl);
|
||||
for (const r of leagueRows) if (r.logoUrl) urls.add(r.logoUrl);
|
||||
for (const r of teamRows) if (r.logoUrl) urls.add(r.logoUrl);
|
||||
for (const r of prefRows) if (r.avatarKey) urls.add(r.avatarKey);
|
||||
return urls;
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
|
||||
Reference in New Issue
Block a user