Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
bcd67a61ac
|
@ -1,4 +1,19 @@
|
|||
import { Controller, Headers, Post, Body, UseGuards, Get, Req, HttpException, HttpStatus, BadRequestException, InternalServerErrorException, NotFoundException, UnauthorizedException, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Headers,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Get,
|
||||
Req,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthSchema, JwtPayload } from '@nice/common';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
@ -7,8 +22,8 @@ import { z } from 'zod';
|
|||
import { FileValidationErrorType } from './types';
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private logger = new Logger(AuthController.name)
|
||||
constructor(private readonly authService: AuthService) { }
|
||||
private logger = new Logger(AuthController.name);
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
@Get('file')
|
||||
async authFileRequset(
|
||||
@Headers('x-original-uri') originalUri: string,
|
||||
|
@ -18,7 +33,6 @@ export class AuthController {
|
|||
@Headers('host') host: string,
|
||||
@Headers('authorization') authorization: string,
|
||||
) {
|
||||
|
||||
try {
|
||||
const fileRequest = {
|
||||
originalUri,
|
||||
|
@ -26,10 +40,11 @@ export class AuthController {
|
|||
method,
|
||||
queryParams,
|
||||
host,
|
||||
authorization
|
||||
authorization,
|
||||
};
|
||||
|
||||
const authResult = await this.authService.validateFileRequest(fileRequest);
|
||||
const authResult =
|
||||
await this.authService.validateFileRequest(fileRequest);
|
||||
if (!authResult.isValid) {
|
||||
// 使用枚举类型进行错误处理
|
||||
switch (authResult.error) {
|
||||
|
@ -41,7 +56,9 @@ export class AuthController {
|
|||
case FileValidationErrorType.INVALID_TOKEN:
|
||||
throw new UnauthorizedException(authResult.error);
|
||||
default:
|
||||
throw new InternalServerErrorException(authResult.error || FileValidationErrorType.UNKNOWN_ERROR);
|
||||
throw new InternalServerErrorException(
|
||||
authResult.error || FileValidationErrorType.UNKNOWN_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
@ -51,17 +68,20 @@ export class AuthController {
|
|||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.verbose(`File request auth failed from ${realIp} reason:${error.message}`)
|
||||
this.logger.verbose(
|
||||
`File request auth failed from ${realIp} reason:${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('user-profile')
|
||||
async getUserProfile(@Req() request: Request) {
|
||||
|
||||
const payload: JwtPayload = (request as any).user;
|
||||
const { staff } = await UserProfileService.instance.getUserProfileById(payload.sub);
|
||||
return staff
|
||||
const { staff } = await UserProfileService.instance.getUserProfileById(
|
||||
payload.sub,
|
||||
);
|
||||
return staff;
|
||||
}
|
||||
@Post('login')
|
||||
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {
|
||||
|
|
|
@ -4,14 +4,9 @@ import {
|
|||
BadRequestException,
|
||||
Logger,
|
||||
InternalServerErrorException,
|
||||
|
||||
} from '@nestjs/common';
|
||||
import { StaffService } from '../models/staff/staff.service';
|
||||
import {
|
||||
db,
|
||||
AuthSchema,
|
||||
JwtPayload,
|
||||
} from '@nice/common';
|
||||
import { db, AuthSchema, JwtPayload } from '@nice/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { redis } from '@server/utils/redis/redis.service';
|
||||
|
@ -24,14 +19,12 @@ import { TusService } from '@server/upload/tus.service';
|
|||
import { extractFileIdFromNginxUrl } from '@server/upload/utils';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private logger = new Logger(AuthService.name)
|
||||
private logger = new Logger(AuthService.name);
|
||||
constructor(
|
||||
private readonly staffService: StaffService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly sessionService: SessionService
|
||||
) {
|
||||
|
||||
}
|
||||
private readonly sessionService: SessionService,
|
||||
) {}
|
||||
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
|
||||
try {
|
||||
// 基础参数验证
|
||||
|
@ -39,27 +32,32 @@ export class AuthService {
|
|||
return { isValid: false, error: FileValidationErrorType.INVALID_URI };
|
||||
}
|
||||
const fileId = extractFileIdFromNginxUrl(params.originalUri);
|
||||
console.log(params.originalUri, fileId)
|
||||
console.log(params.originalUri, fileId);
|
||||
const resource = await db.resource.findFirst({ where: { fileId } });
|
||||
|
||||
// 资源验证
|
||||
if (!resource) {
|
||||
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND };
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
|
||||
};
|
||||
}
|
||||
// 处理公开资源
|
||||
if (resource.isPublic) {
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
resourceType: resource.type || 'unknown'
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
}
|
||||
// 处理私有资源
|
||||
const token = extractTokenFromAuthorization(params.authorization);
|
||||
if (!token) {
|
||||
return { isValid: false, error: FileValidationErrorType.AUTHORIZATION_REQUIRED };
|
||||
return {
|
||||
isValid: false,
|
||||
error: FileValidationErrorType.AUTHORIZATION_REQUIRED,
|
||||
};
|
||||
}
|
||||
const payload: JwtPayload = await this.jwtService.verify(token)
|
||||
const payload: JwtPayload = await this.jwtService.verify(token);
|
||||
if (!payload.sub) {
|
||||
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
|
||||
}
|
||||
|
@ -67,9 +65,8 @@ export class AuthService {
|
|||
return {
|
||||
isValid: true,
|
||||
userId: payload.sub,
|
||||
resourceType: resource.type || 'unknown'
|
||||
resourceType: resource.type || 'unknown',
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('File validation error:', error);
|
||||
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
|
||||
|
@ -93,7 +90,9 @@ export class AuthService {
|
|||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async signIn(data: z.infer<typeof AuthSchema.signInRequset>): Promise<SessionInfo> {
|
||||
async signIn(
|
||||
data: z.infer<typeof AuthSchema.signInRequset>,
|
||||
): Promise<SessionInfo> {
|
||||
const { username, password, phoneNumber } = data;
|
||||
|
||||
let staff = await db.staff.findFirst({
|
||||
|
@ -113,7 +112,8 @@ export class AuthService {
|
|||
if (!staff.enabled) {
|
||||
throw new UnauthorizedException('帐号已禁用');
|
||||
}
|
||||
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password);
|
||||
const isPasswordMatch =
|
||||
phoneNumber || (await argon2.verify(staff.password, password));
|
||||
if (!isPasswordMatch) {
|
||||
throw new UnauthorizedException('帐号或密码错误');
|
||||
}
|
||||
|
@ -143,7 +143,7 @@ export class AuthService {
|
|||
const existingUser = await db.staff.findFirst({
|
||||
where: {
|
||||
OR: [{ username }, { officerId }, { phoneNumber }],
|
||||
deletedAt: null
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -155,7 +155,7 @@ export class AuthService {
|
|||
data: {
|
||||
...data,
|
||||
domainId: data.deptId,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
|
||||
|
@ -168,12 +168,17 @@ export class AuthService {
|
|||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const session = await this.sessionService.getSession(payload.sub, sessionId);
|
||||
const session = await this.sessionService.getSession(
|
||||
payload.sub,
|
||||
sessionId,
|
||||
);
|
||||
if (!session || session.refresh_token !== refreshToken) {
|
||||
throw new UnauthorizedException('用户会话已过期');
|
||||
}
|
||||
|
||||
const user = await db.staff.findUnique({ where: { id: payload.sub, deletedAt: null } });
|
||||
const user = await db.staff.findUnique({
|
||||
where: { id: payload.sub, deletedAt: null },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('用户不存在');
|
||||
}
|
||||
|
@ -186,14 +191,17 @@ export class AuthService {
|
|||
const updatedSession = {
|
||||
...session,
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: Date.now() + tokenConfig.accessToken.expirationMs,
|
||||
access_token_expires_at:
|
||||
Date.now() + tokenConfig.accessToken.expirationMs,
|
||||
};
|
||||
await this.sessionService.saveSession(
|
||||
payload.sub,
|
||||
updatedSession,
|
||||
tokenConfig.accessToken.expirationTTL,
|
||||
);
|
||||
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub));
|
||||
await redis.del(
|
||||
UserProfileService.instance.getProfileCacheKey(payload.sub),
|
||||
);
|
||||
return {
|
||||
access_token: accessToken,
|
||||
access_token_expires_at: updatedSession.access_token_expires_at,
|
||||
|
@ -212,7 +220,7 @@ export class AuthService {
|
|||
where: { id: user?.id },
|
||||
data: {
|
||||
password: newPassword,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { message: '密码已修改' };
|
||||
|
@ -232,5 +240,4 @@ export class AuthService {
|
|||
|
||||
return { message: '注销成功' };
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ async function bootstrap() {
|
|||
|
||||
// 启用 CORS 并允许所有来源
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
origin: '*',
|
||||
});
|
||||
const wsService = app.get(WebSocketService);
|
||||
await wsService.initialize(app.getHttpServer());
|
||||
|
@ -18,6 +18,5 @@ async function bootstrap() {
|
|||
const port = process.env.SERVER_PORT || 3000;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
@ -1,238 +1,307 @@
|
|||
import { Logger } from "@nestjs/common";
|
||||
import { UserProfile, db, RowModelRequest } from "@nice/common";
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { UserProfile, db, RowModelRequest } from '@nice/common';
|
||||
import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder';
|
||||
export interface GetRowOptions {
|
||||
id?: string;
|
||||
ids?: string[];
|
||||
extraCondition?: LogicalCondition;
|
||||
staff?: UserProfile;
|
||||
id?: string;
|
||||
ids?: string[];
|
||||
extraCondition?: LogicalCondition;
|
||||
staff?: UserProfile;
|
||||
}
|
||||
export abstract class RowModelService {
|
||||
private keywords: Set<string> = new Set([
|
||||
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'JOIN', 'AND', 'OR'
|
||||
// 添加更多需要引号的关键词
|
||||
]);
|
||||
protected logger = new Logger(this.tableName);
|
||||
protected constructor(protected tableName: string) { }
|
||||
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||
return row;
|
||||
private keywords: Set<string> = new Set([
|
||||
'SELECT',
|
||||
'FROM',
|
||||
'WHERE',
|
||||
'ORDER',
|
||||
'BY',
|
||||
'GROUP',
|
||||
'JOIN',
|
||||
'AND',
|
||||
'OR',
|
||||
// 添加更多需要引号的关键词
|
||||
]);
|
||||
protected logger = new Logger(this.tableName);
|
||||
protected constructor(protected tableName: string) {}
|
||||
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||
return row;
|
||||
}
|
||||
protected async getRowsSqlWrapper(
|
||||
sql: string,
|
||||
request?: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
) {
|
||||
if (request) return SQLBuilder.join([sql, this.getLimitSql(request)]);
|
||||
return sql;
|
||||
}
|
||||
protected getLimitSql(request: RowModelRequest) {
|
||||
return SQLBuilder.limit(
|
||||
request.endRow - request.startRow,
|
||||
request.startRow,
|
||||
);
|
||||
}
|
||||
abstract createJoinSql(request?: RowModelRequest): string[];
|
||||
async getRows(request: RowModelRequest, staff?: UserProfile) {
|
||||
try {
|
||||
let SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.getRowSelectCols(request)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||
SQLBuilder.groupBy(this.getGroupByColumns(request)),
|
||||
SQLBuilder.orderBy(this.getOrderByColumns(request)),
|
||||
]);
|
||||
SQL = await this.getRowsSqlWrapper(SQL, request, staff);
|
||||
|
||||
this.logger.debug('getrows', SQL);
|
||||
|
||||
const results: any[] = (await db?.$queryRawUnsafe(SQL)) || [];
|
||||
|
||||
const rowDataDto = await Promise.all(
|
||||
results.map((row) => this.getRowDto(row, staff)),
|
||||
);
|
||||
return {
|
||||
rowCount: this.getRowCount(request, rowDataDto) || 0,
|
||||
rowData: rowDataDto,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error executing getRows:', error);
|
||||
}
|
||||
protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) {
|
||||
if (request)
|
||||
return SQLBuilder.join([sql, this.getLimitSql(request)])
|
||||
return sql
|
||||
}
|
||||
getRowCount(request: RowModelRequest, results: any[]) {
|
||||
if (results === null || results === undefined || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
protected getLimitSql(request: RowModelRequest) {
|
||||
return SQLBuilder.limit(request.endRow - request.startRow, request.startRow)
|
||||
const currentLastRow = request.startRow + results.length;
|
||||
return currentLastRow <= request.endRow ? currentLastRow : -1;
|
||||
}
|
||||
|
||||
async getRowById(options: GetRowOptions): Promise<any> {
|
||||
const {
|
||||
id,
|
||||
extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
staff,
|
||||
} = options;
|
||||
return this.getSingleRow(
|
||||
{ AND: [this.createGetByIdFilter(id!), extraCondition] },
|
||||
staff,
|
||||
);
|
||||
}
|
||||
|
||||
async getRowByIds(options: GetRowOptions): Promise<any[]> {
|
||||
const {
|
||||
ids,
|
||||
extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: 'blank',
|
||||
type: 'date',
|
||||
},
|
||||
staff,
|
||||
} = options;
|
||||
return this.getMultipleRows(
|
||||
{ AND: [this.createGetByIdsFilter(ids!), extraCondition] },
|
||||
staff,
|
||||
);
|
||||
}
|
||||
|
||||
protected createGetRowsFilters(
|
||||
request: RowModelRequest,
|
||||
staff?: UserProfile,
|
||||
): LogicalCondition {
|
||||
let groupConditions: LogicalCondition[] = [];
|
||||
if (this.isDoingTreeGroup(request)) {
|
||||
groupConditions = [
|
||||
{
|
||||
field: 'parent_id',
|
||||
op: 'equals' as OperatorType,
|
||||
value: request.groupKeys[request.groupKeys.length - 1],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
groupConditions = request?.groupKeys?.map((key, index) => ({
|
||||
field: request.rowGroupCols[index].field,
|
||||
op: 'equals' as OperatorType,
|
||||
value: key,
|
||||
}));
|
||||
}
|
||||
abstract createJoinSql(request?: RowModelRequest): string[];
|
||||
async getRows(request: RowModelRequest, staff?: UserProfile) {
|
||||
try {
|
||||
|
||||
let SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.getRowSelectCols(request)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
|
||||
SQLBuilder.groupBy(this.getGroupByColumns(request)),
|
||||
SQLBuilder.orderBy(this.getOrderByColumns(request)),
|
||||
]);
|
||||
SQL = await this.getRowsSqlWrapper(SQL, request, staff)
|
||||
const condition: LogicalCondition = {
|
||||
AND: [
|
||||
...groupConditions,
|
||||
...this.buildFilterConditions(request.filterModel),
|
||||
],
|
||||
};
|
||||
|
||||
this.logger.debug('getrows', SQL)
|
||||
return condition;
|
||||
}
|
||||
private buildFilterConditions(filterModel: any): LogicalCondition[] {
|
||||
return filterModel
|
||||
? Object.entries(filterModel)?.map(([key, item]) =>
|
||||
SQLBuilder.createFilterSql(
|
||||
key === 'ag-Grid-AutoColumn' ? 'name' : key,
|
||||
item,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
const results: any[] = await db?.$queryRawUnsafe(SQL) || [];
|
||||
getRowSelectCols(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? this.createGroupingRowSelect(request)
|
||||
: this.createUnGroupingRowSelect(request);
|
||||
}
|
||||
protected createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
protected createAggSqlForWrapper(request: RowModelRequest) {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
return valueCols.map(
|
||||
(valueCol) =>
|
||||
`${valueCol.aggFunc}(${valueCol.field.replace('.', '_')}) AS ${valueCol.field.split('.').join('_')}`,
|
||||
);
|
||||
}
|
||||
protected createGroupingRowSelect(
|
||||
request: RowModelRequest,
|
||||
wrapperSql: boolean = false,
|
||||
): string[] {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
const colsToSelect: string[] = [];
|
||||
|
||||
let rowDataDto = await Promise.all(results.map(row => this.getRowDto(row, staff)))
|
||||
return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto };
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error executing getRows:', error);
|
||||
const rowGroupCol = rowGroupCols[groupKeys!.length];
|
||||
if (rowGroupCol) {
|
||||
colsToSelect.push(
|
||||
`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`,
|
||||
);
|
||||
}
|
||||
colsToSelect.push(
|
||||
...valueCols.map(
|
||||
(valueCol) =>
|
||||
`${wrapperSql ? '' : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
|
||||
),
|
||||
);
|
||||
|
||||
return colsToSelect;
|
||||
}
|
||||
|
||||
getGroupByColumns(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? [request.rowGroupCols[request.groupKeys!.length]?.field]
|
||||
: [];
|
||||
}
|
||||
|
||||
getOrderByColumns(request: RowModelRequest): string[] {
|
||||
const { sortModel, rowGroupCols, groupKeys } = request;
|
||||
const grouping = this.isDoingGroup(request);
|
||||
const sortParts: string[] = [];
|
||||
|
||||
if (sortModel) {
|
||||
const groupColIds = rowGroupCols
|
||||
.map((groupCol) => groupCol.id)
|
||||
.slice(0, groupKeys.length + 1);
|
||||
sortModel.forEach((item) => {
|
||||
if (
|
||||
!grouping ||
|
||||
(groupColIds.indexOf(item.colId) >= 0 &&
|
||||
rowGroupCols[groupKeys.length].field === item.colId)
|
||||
) {
|
||||
const colId = this.keywords.has(item.colId.toUpperCase())
|
||||
? `"${item.colId}"`
|
||||
: item.colId;
|
||||
sortParts.push(`${colId} ${item.sort}`);
|
||||
}
|
||||
}
|
||||
getRowCount(request: RowModelRequest, results: any[]) {
|
||||
if (results === null || results === undefined || results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const currentLastRow = request.startRow + results.length;
|
||||
return currentLastRow <= request.endRow ? currentLastRow : -1;
|
||||
});
|
||||
}
|
||||
|
||||
async getRowById(options: GetRowOptions): Promise<any> {
|
||||
const { id, extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: "blank",
|
||||
type: "date"
|
||||
}, staff } = options;
|
||||
return this.getSingleRow({ AND: [this.createGetByIdFilter(id!), extraCondition] }, staff);
|
||||
}
|
||||
return sortParts;
|
||||
}
|
||||
isDoingGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length > requset.groupKeys.length;
|
||||
}
|
||||
isDoingTreeGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0;
|
||||
}
|
||||
private async getSingleRow(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
const results = await this.getRowsWithFilters(condition, staff);
|
||||
return results[0];
|
||||
}
|
||||
private async getMultipleRows(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any[]> {
|
||||
return this.getRowsWithFilters(condition, staff);
|
||||
}
|
||||
|
||||
async getRowByIds(options: GetRowOptions): Promise<any[]> {
|
||||
const { ids, extraCondition = {
|
||||
field: `${this.tableName}.deleted_at`,
|
||||
op: "blank",
|
||||
type: "date"
|
||||
}, staff } = options;
|
||||
return this.getMultipleRows({ AND: [this.createGetByIdsFilter(ids!), extraCondition] }, staff);
|
||||
}
|
||||
private async getRowsWithFilters(
|
||||
condition: LogicalCondition,
|
||||
staff?: UserProfile,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.createUnGroupingRowSelect()),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql()),
|
||||
SQLBuilder.where(condition),
|
||||
]);
|
||||
|
||||
protected createGetRowsFilters(request: RowModelRequest, staff?: UserProfile): LogicalCondition {
|
||||
let groupConditions: LogicalCondition[] = []
|
||||
if (this.isDoingTreeGroup(request)) {
|
||||
groupConditions = [
|
||||
{
|
||||
field: 'parent_id',
|
||||
op: "equals" as OperatorType,
|
||||
value: request.groupKeys[request.groupKeys.length - 1]
|
||||
}
|
||||
]
|
||||
} else {
|
||||
groupConditions = request?.groupKeys?.map((key, index) => ({
|
||||
field: request.rowGroupCols[index].field,
|
||||
op: "equals" as OperatorType,
|
||||
value: key
|
||||
}))
|
||||
}
|
||||
// this.logger.debug(SQL)
|
||||
const results: any[] = await db.$queryRawUnsafe(SQL);
|
||||
|
||||
const condition: LogicalCondition = {
|
||||
AND: [...groupConditions, ...this.buildFilterConditions(request.filterModel)]
|
||||
}
|
||||
const rowDataDto = await Promise.all(
|
||||
results.map((item) => this.getRowDto(item, staff)),
|
||||
);
|
||||
|
||||
return condition;
|
||||
}
|
||||
private buildFilterConditions(filterModel: any): LogicalCondition[] {
|
||||
return filterModel
|
||||
? Object.entries(filterModel)?.map(([key, item]) => SQLBuilder.createFilterSql(key === 'ag-Grid-AutoColumn' ? 'name' : key, item))
|
||||
: [];
|
||||
// rowDataDto = getUniqueItems(rowDataDto, "id")
|
||||
return rowDataDto;
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getRowSelectCols(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? this.createGroupingRowSelect(request)
|
||||
: this.createUnGroupingRowSelect(request);
|
||||
async getAggValues(request: RowModelRequest) {
|
||||
try {
|
||||
const SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.buildAggSelect(request.valueCols)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request)),
|
||||
SQLBuilder.groupBy(this.buildAggGroupBy()),
|
||||
]);
|
||||
const result: any[] = await db.$queryRawUnsafe(SQL);
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
protected createUnGroupingRowSelect(request?: RowModelRequest): string[] {
|
||||
return ['*'];
|
||||
}
|
||||
protected createAggSqlForWrapper(request: RowModelRequest) {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
return valueCols.map(valueCol =>
|
||||
`${valueCol.aggFunc}(${valueCol.field.replace('.', '_')}) AS ${valueCol.field.split('.').join('_')}`
|
||||
);
|
||||
}
|
||||
protected createGroupingRowSelect(request: RowModelRequest, wrapperSql: boolean = false): string[] {
|
||||
const { rowGroupCols, valueCols, groupKeys } = request;
|
||||
const colsToSelect: string[] = [];
|
||||
}
|
||||
protected buildAggGroupBy(): string[] {
|
||||
return [];
|
||||
}
|
||||
protected buildAggSelect(valueCols: any[]): string[] {
|
||||
return valueCols.map(
|
||||
(valueCol) =>
|
||||
`${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rowGroupCol = rowGroupCols[groupKeys!.length];
|
||||
if (rowGroupCol) {
|
||||
colsToSelect.push(`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`);
|
||||
}
|
||||
colsToSelect.push(...valueCols.map(valueCol =>
|
||||
`${wrapperSql ? "" : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`
|
||||
));
|
||||
|
||||
return colsToSelect;
|
||||
}
|
||||
|
||||
getGroupByColumns(request: RowModelRequest): string[] {
|
||||
return this.isDoingGroup(request)
|
||||
? [request.rowGroupCols[request.groupKeys!.length]?.field]
|
||||
: [];
|
||||
}
|
||||
|
||||
|
||||
getOrderByColumns(request: RowModelRequest): string[] {
|
||||
const { sortModel, rowGroupCols, groupKeys } = request;
|
||||
const grouping = this.isDoingGroup(request);
|
||||
const sortParts: string[] = [];
|
||||
|
||||
if (sortModel) {
|
||||
const groupColIds = rowGroupCols.map(groupCol => groupCol.id).slice(0, groupKeys.length + 1);
|
||||
sortModel.forEach(item => {
|
||||
if (!grouping || (groupColIds.indexOf(item.colId) >= 0 && rowGroupCols[groupKeys.length].field === item.colId)) {
|
||||
const colId = this.keywords.has(item.colId.toUpperCase()) ? `"${item.colId}"` : item.colId;
|
||||
sortParts.push(`${colId} ${item.sort}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return sortParts;
|
||||
}
|
||||
isDoingGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length > requset.groupKeys.length;
|
||||
}
|
||||
isDoingTreeGroup(requset: RowModelRequest): boolean {
|
||||
return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0;
|
||||
}
|
||||
private async getSingleRow(condition: LogicalCondition, staff?: UserProfile): Promise<any> {
|
||||
const results = await this.getRowsWithFilters(condition, staff)
|
||||
return results[0]
|
||||
}
|
||||
private async getMultipleRows(condition: LogicalCondition, staff?: UserProfile): Promise<any[]> {
|
||||
return this.getRowsWithFilters(condition, staff);
|
||||
}
|
||||
|
||||
private async getRowsWithFilters(condition: LogicalCondition, staff?: UserProfile): Promise<any[]> {
|
||||
try {
|
||||
let SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.createUnGroupingRowSelect()),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql()),
|
||||
SQLBuilder.where(condition)
|
||||
]);
|
||||
|
||||
// this.logger.debug(SQL)
|
||||
const results: any[] = await db.$queryRawUnsafe(SQL);
|
||||
|
||||
let rowDataDto = await Promise.all(results.map(item => this.getRowDto(item, staff)));
|
||||
|
||||
// rowDataDto = getUniqueItems(rowDataDto, "id")
|
||||
return rowDataDto
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAggValues(request: RowModelRequest) {
|
||||
try {
|
||||
const SQL = SQLBuilder.join([
|
||||
SQLBuilder.select(this.buildAggSelect(request.valueCols)),
|
||||
SQLBuilder.from(this.tableName),
|
||||
SQLBuilder.join(this.createJoinSql(request)),
|
||||
SQLBuilder.where(this.createGetRowsFilters(request)),
|
||||
SQLBuilder.groupBy(this.buildAggGroupBy())
|
||||
]);
|
||||
const result: any[] = await db.$queryRawUnsafe(SQL);
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
this.logger.error('Error executing query:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
protected buildAggGroupBy(): string[] {
|
||||
return [];
|
||||
}
|
||||
protected buildAggSelect(valueCols: any[]): string[] {
|
||||
return valueCols.map(valueCol =>
|
||||
`${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`
|
||||
);
|
||||
}
|
||||
|
||||
private createGetByIdFilter(id: string): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: id,
|
||||
op: "equals"
|
||||
}
|
||||
}
|
||||
private createGetByIdsFilter(ids: string[]): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: ids,
|
||||
op: "in"
|
||||
};
|
||||
}
|
||||
private createGetByIdFilter(id: string): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: id,
|
||||
op: 'equals',
|
||||
};
|
||||
}
|
||||
private createGetByIdsFilter(ids: string[]): LogicalCondition {
|
||||
return {
|
||||
field: `${this.tableName}.id`,
|
||||
value: ids,
|
||||
op: 'in',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,84 +3,92 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
|||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { CourseService } from './course.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any()
|
||||
const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any()
|
||||
const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> = z.any()
|
||||
const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> = z.any()
|
||||
const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any()
|
||||
const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any()
|
||||
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any()
|
||||
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any()
|
||||
const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any();
|
||||
const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any();
|
||||
const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> =
|
||||
z.any();
|
||||
const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> =
|
||||
z.any();
|
||||
const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any();
|
||||
const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any();
|
||||
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any();
|
||||
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any();
|
||||
|
||||
@Injectable()
|
||||
export class CourseRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly courseService: CourseService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(CourseCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.courseService.create(input, { staff });
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(CourseUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.courseService.update(input, { staff });
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly courseService: CourseService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(CourseCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.courseService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(CourseDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.courseService.deleteMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.courseService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.courseService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().optional(),
|
||||
where: CourseWhereInputSchema.optional(),
|
||||
select: CourseSelectSchema.optional()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await this.courseService.findManyWithCursor(input);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
where: CourseWhereInputSchema.optional(),
|
||||
select: CourseSelectSchema.optional()
|
||||
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findManyWithPagination(input);
|
||||
}),
|
||||
});
|
||||
return await this.courseService.create(input, { staff });
|
||||
}),
|
||||
update: this.trpc.protectProcedure
|
||||
.input(CourseUpdateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.courseService.update(input, { staff });
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure
|
||||
.input(z.array(CourseCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.courseService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(CourseDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.courseService.deleteMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.courseService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.courseService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().optional(),
|
||||
where: CourseWhereInputSchema.optional(),
|
||||
select: CourseSelectSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await this.courseService.findManyWithCursor(input);
|
||||
}),
|
||||
findManyWithPagination: this.trpc.procedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
where: CourseWhereInputSchema.optional(),
|
||||
select: CourseSelectSchema.optional(),
|
||||
}),
|
||||
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.courseService.findManyWithPagination(input);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,46 +1,49 @@
|
|||
import { db, EnrollmentStatus, PostType } from "@nice/common";
|
||||
import { db, EnrollmentStatus, PostType } from '@nice/common';
|
||||
|
||||
// 更新课程评价统计
|
||||
export async function updateCourseReviewStats(courseId: string) {
|
||||
const reviews = await db.post.findMany({
|
||||
where: {
|
||||
courseId,
|
||||
type: PostType.COURSE_REVIEW,
|
||||
deletedAt: null
|
||||
},
|
||||
select: { rating: true }
|
||||
});
|
||||
const numberOfReviews = reviews.length;
|
||||
const averageRating = numberOfReviews > 0
|
||||
? reviews.reduce((sum, review) => sum + review.rating, 0) / numberOfReviews
|
||||
: 0;
|
||||
const reviews = await db.post.findMany({
|
||||
where: {
|
||||
courseId,
|
||||
type: PostType.COURSE_REVIEW,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { rating: true },
|
||||
});
|
||||
const numberOfReviews = reviews.length;
|
||||
const averageRating =
|
||||
numberOfReviews > 0
|
||||
? reviews.reduce((sum, review) => sum + review.rating, 0) /
|
||||
numberOfReviews
|
||||
: 0;
|
||||
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: { numberOfReviews, averageRating }
|
||||
});
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfReviews,
|
||||
//averageRating,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 更新课程注册统计
|
||||
export async function updateCourseEnrollmentStats(courseId: string) {
|
||||
const completedEnrollments = await db.enrollment.count({
|
||||
where: {
|
||||
courseId,
|
||||
status: EnrollmentStatus.COMPLETED
|
||||
}
|
||||
});
|
||||
const totalEnrollments = await db.enrollment.count({
|
||||
where: { courseId }
|
||||
});
|
||||
const completionRate = totalEnrollments > 0
|
||||
? (completedEnrollments / totalEnrollments) * 100
|
||||
: 0;
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
numberOfStudents: totalEnrollments,
|
||||
completionRate
|
||||
}
|
||||
});
|
||||
const completedEnrollments = await db.enrollment.count({
|
||||
where: {
|
||||
courseId,
|
||||
status: EnrollmentStatus.COMPLETED,
|
||||
},
|
||||
});
|
||||
const totalEnrollments = await db.enrollment.count({
|
||||
where: { courseId },
|
||||
});
|
||||
const completionRate =
|
||||
totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
|
||||
return db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
// numberOfStudents: totalEnrollments,
|
||||
// completionRate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,39 +1,39 @@
|
|||
import { db, Lecture } from "@nice/common"
|
||||
import { db, Lecture } from '@nice/common';
|
||||
|
||||
export async function updateSectionLectureStats(sectionId: string) {
|
||||
const sectionStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
sectionId,
|
||||
deletedAt: null
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true }
|
||||
});
|
||||
const sectionStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
sectionId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true },
|
||||
});
|
||||
|
||||
await db.section.update({
|
||||
where: { id: sectionId },
|
||||
data: {
|
||||
totalLectures: sectionStats._count._all,
|
||||
totalDuration: sectionStats._sum.duration || 0
|
||||
}
|
||||
});
|
||||
await db.section.update({
|
||||
where: { id: sectionId },
|
||||
data: {
|
||||
// totalLectures: sectionStats._count._all,
|
||||
// totalDuration: sectionStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCourseLectureStats(courseId: string) {
|
||||
const courseStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
courseId,
|
||||
deletedAt: null
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true }
|
||||
});
|
||||
const courseStats = await db.lecture.aggregate({
|
||||
where: {
|
||||
courseId,
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { duration: true },
|
||||
});
|
||||
|
||||
await db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
totalLectures: courseStats._count._all,
|
||||
totalDuration: courseStats._sum.duration || 0
|
||||
}
|
||||
});
|
||||
}
|
||||
await db.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
//totalLectures: courseStats._count._all,
|
||||
//totalDuration: courseStats._sum.duration || 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -17,20 +17,22 @@ export class TermRowService extends RowCacheService {
|
|||
createUnGroupingRowSelect(
|
||||
requset: z.infer<typeof TermMethodSchema.getRows>,
|
||||
): string[] {
|
||||
const result = super.createUnGroupingRowSelect(requset).concat([
|
||||
`${this.tableName}.name AS name`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.has_children AS has_children`,
|
||||
`${this.tableName}.parent_id AS parent_id`,
|
||||
`${this.tableName}.domain_id AS domain_id`,
|
||||
`taxonomy.name AS taxonomy_name`,
|
||||
`taxonomy.id AS taxonomy_id`
|
||||
]);
|
||||
const result = super
|
||||
.createUnGroupingRowSelect(requset)
|
||||
.concat([
|
||||
`${this.tableName}.name AS name`,
|
||||
`${this.tableName}.order AS order`,
|
||||
`${this.tableName}.has_children AS has_children`,
|
||||
`${this.tableName}.parent_id AS parent_id`,
|
||||
`${this.tableName}.domain_id AS domain_id`,
|
||||
`taxonomy.name AS taxonomy_name`,
|
||||
`taxonomy.id AS taxonomy_id`,
|
||||
]);
|
||||
return result;
|
||||
}
|
||||
createJoinSql(request?: RowModelRequest): string[] {
|
||||
return [
|
||||
`LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id`
|
||||
`LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id`,
|
||||
];
|
||||
}
|
||||
protected createGetRowsFilters(
|
||||
|
@ -53,7 +55,7 @@ export class TermRowService extends RowCacheService {
|
|||
} else if (parentId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.parent_id`,
|
||||
op: "blank",
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -66,7 +68,7 @@ export class TermRowService extends RowCacheService {
|
|||
} else if (domainId === null) {
|
||||
condition.AND.push({
|
||||
field: `${this.tableName}.domain_id`,
|
||||
op: "blank",
|
||||
op: 'blank',
|
||||
});
|
||||
}
|
||||
if (taxonomyId) {
|
||||
|
@ -84,8 +86,6 @@ export class TermRowService extends RowCacheService {
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
return condition;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,28 +1,24 @@
|
|||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import EventBus from "@server/utils/event-bus";
|
||||
import { Queue } from "bullmq";
|
||||
import { ObjectType } from "@nice/common";
|
||||
import { QueueJobType } from "../types";
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ObjectType } from '@nice/common';
|
||||
import { QueueJobType } from '../types';
|
||||
@Injectable()
|
||||
export class PostProcessService {
|
||||
constructor(
|
||||
@InjectQueue('general') private generalQueue: Queue
|
||||
) {
|
||||
|
||||
}
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue) {}
|
||||
|
||||
private generateJobId(type: ObjectType, data: any): string {
|
||||
// 根据类型和相关ID生成唯一的job标识
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
case ObjectType.LECTURE:
|
||||
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
||||
case ObjectType.POST:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
default:
|
||||
return `stats_${type}_${Date.now()}`;
|
||||
}
|
||||
private generateJobId(type: ObjectType, data: any): string {
|
||||
// 根据类型和相关ID生成唯一的job标识
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
case ObjectType.LECTURE:
|
||||
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
||||
case ObjectType.POST:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
default:
|
||||
return `stats_${type}_${Date.now()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,11 +25,10 @@ import { join } from 'path';
|
|||
{
|
||||
name: 'file-queue', // 新增文件处理队列
|
||||
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
providers: [Logger],
|
||||
exports: []
|
||||
|
||||
exports: [],
|
||||
})
|
||||
export class QueueModule { }
|
||||
export class QueueModule {}
|
||||
|
|
|
@ -1,70 +1,68 @@
|
|||
import { InjectQueue } from "@nestjs/bullmq";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import EventBus from "@server/utils/event-bus";
|
||||
import { Queue } from "bullmq";
|
||||
import { ObjectType } from "@nice/common";
|
||||
import { QueueJobType } from "../types";
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import EventBus from '@server/utils/event-bus';
|
||||
import { Queue } from 'bullmq';
|
||||
import { ObjectType } from '@nice/common';
|
||||
import { QueueJobType } from '../types';
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
constructor(
|
||||
@InjectQueue('general') private generalQueue: Queue
|
||||
) {
|
||||
EventBus.on("dataChanged", async ({ type, data }) => {
|
||||
const jobOptions = {
|
||||
removeOnComplete: true,
|
||||
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务
|
||||
};
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.ENROLLMENT
|
||||
},
|
||||
jobOptions
|
||||
);
|
||||
break;
|
||||
constructor(@InjectQueue('general') private generalQueue: Queue) {
|
||||
EventBus.on('dataChanged', async ({ type, data }) => {
|
||||
const jobOptions = {
|
||||
removeOnComplete: true,
|
||||
jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
|
||||
};
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.ENROLLMENT,
|
||||
},
|
||||
jobOptions,
|
||||
);
|
||||
break;
|
||||
|
||||
case ObjectType.LECTURE:
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
sectionId: data.sectionId,
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.LECTURE
|
||||
},
|
||||
jobOptions
|
||||
);
|
||||
break;
|
||||
case ObjectType.LECTURE:
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
sectionId: data.sectionId,
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.LECTURE,
|
||||
},
|
||||
jobOptions,
|
||||
);
|
||||
break;
|
||||
|
||||
case ObjectType.POST:
|
||||
if (data.courseId) {
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.POST
|
||||
},
|
||||
jobOptions
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
case ObjectType.POST:
|
||||
if (data.courseId) {
|
||||
await this.generalQueue.add(
|
||||
QueueJobType.UPDATE_STATS,
|
||||
{
|
||||
courseId: data.courseId,
|
||||
type: ObjectType.POST,
|
||||
},
|
||||
jobOptions,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generateJobId(type: ObjectType, data: any): string {
|
||||
// 根据类型和相关ID生成唯一的job标识
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
case ObjectType.LECTURE:
|
||||
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
||||
case ObjectType.POST:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
default:
|
||||
return `stats_${type}_${Date.now()}`;
|
||||
}
|
||||
|
||||
private generateJobId(type: ObjectType, data: any): string {
|
||||
// 根据类型和相关ID生成唯一的job标识
|
||||
switch (type) {
|
||||
case ObjectType.ENROLLMENT:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
case ObjectType.LECTURE:
|
||||
return `stats_${type}_${data.courseId}_${data.sectionId}`;
|
||||
case ObjectType.POST:
|
||||
return `stats_${type}_${data.courseId}`;
|
||||
default:
|
||||
return `stats_${type}_${Date.now()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export enum QueueJobType {
|
||||
UPDATE_STATS = "update_stats",
|
||||
FILE_PROCESS = "file_process"
|
||||
UPDATE_STATS = 'update_stats',
|
||||
FILE_PROCESS = 'file_process',
|
||||
}
|
||||
|
|
|
@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor
|
|||
import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor';
|
||||
const logger = new Logger('FileProcessorWorker');
|
||||
const pipeline = new ResourceProcessingPipeline()
|
||||
.addProcessor(new ImageProcessor())
|
||||
.addProcessor(new VideoProcessor())
|
||||
.addProcessor(new ImageProcessor())
|
||||
.addProcessor(new VideoProcessor());
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
if (job.name === QueueJobType.FILE_PROCESS) {
|
||||
console.log(job)
|
||||
const { resource } = job.data;
|
||||
if (!resource) {
|
||||
throw new Error('No resource provided in job data');
|
||||
}
|
||||
const result = await pipeline.execute(resource);
|
||||
|
||||
return result;
|
||||
if (job.name === QueueJobType.FILE_PROCESS) {
|
||||
console.log('job', job);
|
||||
const { resource } = job.data;
|
||||
if (!resource) {
|
||||
throw new Error('No resource provided in job data');
|
||||
}
|
||||
}
|
||||
const result = await pipeline.execute(resource);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,49 +1,52 @@
|
|||
import { Job } from 'bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
updateCourseLectureStats,
|
||||
updateSectionLectureStats
|
||||
updateCourseLectureStats,
|
||||
updateSectionLectureStats,
|
||||
} from '@server/models/lecture/utils';
|
||||
import { ObjectType } from '@nice/common';
|
||||
import {
|
||||
updateCourseEnrollmentStats,
|
||||
updateCourseReviewStats
|
||||
updateCourseEnrollmentStats,
|
||||
updateCourseReviewStats,
|
||||
} from '@server/models/course/utils';
|
||||
import { QueueJobType } from '../types';
|
||||
const logger = new Logger('QueueWorker');
|
||||
export default async function processJob(job: Job<any, any, QueueJobType>) {
|
||||
try {
|
||||
if (job.name === QueueJobType.UPDATE_STATS) {
|
||||
const { sectionId, courseId, type } = job.data;
|
||||
// 处理 section 统计
|
||||
if (sectionId) {
|
||||
await updateSectionLectureStats(sectionId);
|
||||
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||
}
|
||||
// 如果没有 courseId,提前返回
|
||||
if (!courseId) {
|
||||
return;
|
||||
}
|
||||
// 处理 course 相关统计
|
||||
switch (type) {
|
||||
case ObjectType.LECTURE:
|
||||
await updateCourseLectureStats(courseId);
|
||||
break;
|
||||
case ObjectType.ENROLLMENT:
|
||||
await updateCourseEnrollmentStats(courseId);
|
||||
break;
|
||||
case ObjectType.POST:
|
||||
await updateCourseReviewStats(courseId);
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown update stats type: ${type}`);
|
||||
}
|
||||
try {
|
||||
if (job.name === QueueJobType.UPDATE_STATS) {
|
||||
const { sectionId, courseId, type } = job.data;
|
||||
// 处理 section 统计
|
||||
if (sectionId) {
|
||||
await updateSectionLectureStats(sectionId);
|
||||
logger.debug(`Updated section stats for sectionId: ${sectionId}`);
|
||||
}
|
||||
// 如果没有 courseId,提前返回
|
||||
if (!courseId) {
|
||||
return;
|
||||
}
|
||||
// 处理 course 相关统计
|
||||
switch (type) {
|
||||
case ObjectType.LECTURE:
|
||||
await updateCourseLectureStats(courseId);
|
||||
break;
|
||||
case ObjectType.ENROLLMENT:
|
||||
await updateCourseEnrollmentStats(courseId);
|
||||
break;
|
||||
case ObjectType.POST:
|
||||
await updateCourseReviewStats(courseId);
|
||||
break;
|
||||
default:
|
||||
logger.warn(`Unknown update stats type: ${type}`);
|
||||
}
|
||||
|
||||
logger.debug(`Updated course stats for courseId: ${courseId}, type: ${type}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error processing stats update job: ${error.message}`, error.stack);
|
||||
logger.debug(
|
||||
`Updated course stats for courseId: ${courseId}, type: ${type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
`Error processing stats update job: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ export class InitService {
|
|||
private readonly minioService: MinioService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly genDevService: GenDevService,
|
||||
) { }
|
||||
) {}
|
||||
private async createRoles() {
|
||||
this.logger.log('Checking existing system roles');
|
||||
for (const role of InitRoles) {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { Server, Uid, Upload } from "@nice/tus"
|
||||
import { Server, Uid, Upload } from '@nice/tus';
|
||||
import { FileStore } from '@nice/tus';
|
||||
import { Request, Response } from "express"
|
||||
import { Request, Response } from 'express';
|
||||
import { db, ResourceStatus } from '@nice/common';
|
||||
import { getFilenameWithoutExt } from '@server/utils/file';
|
||||
import { ResourceService } from '@server/models/resource/resource.service';
|
||||
|
@ -12,104 +12,122 @@ import { QueueJobType } from '@server/queue/types';
|
|||
import { nanoid } from 'nanoid-cjs';
|
||||
import { slugify } from 'transliteration';
|
||||
const FILE_UPLOAD_CONFIG = {
|
||||
directory: process.env.UPLOAD_DIR,
|
||||
maxSizeBytes: 20_000_000_000, // 20GB
|
||||
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours
|
||||
directory: process.env.UPLOAD_DIR,
|
||||
maxSizeBytes: 20_000_000_000, // 20GB
|
||||
expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours
|
||||
};
|
||||
@Injectable()
|
||||
export class TusService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TusService.name);
|
||||
private tusServer: Server;
|
||||
constructor(private readonly resourceService: ResourceService,
|
||||
@InjectQueue("file-queue") private fileQueue: Queue
|
||||
) { }
|
||||
onModuleInit() {
|
||||
this.initializeTusServer();
|
||||
this.setupTusEventHandlers();
|
||||
}
|
||||
private initializeTusServer() {
|
||||
this.tusServer = new Server({
|
||||
namingFunction(req, metadata) {
|
||||
const safeFilename = slugify(metadata.filename);
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const uniqueId = nanoid(10);
|
||||
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
|
||||
},
|
||||
path: '/upload',
|
||||
datastore: new FileStore({
|
||||
directory: FILE_UPLOAD_CONFIG.directory,
|
||||
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod
|
||||
}),
|
||||
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
||||
postReceiveInterval: 1000,
|
||||
getFileIdFromRequest: (req, lastPath) => {
|
||||
const match = req.url.match(/\/upload\/(.+)/);
|
||||
return match ? match[1] : lastPath;
|
||||
}
|
||||
});
|
||||
}
|
||||
private readonly logger = new Logger(TusService.name);
|
||||
private tusServer: Server;
|
||||
constructor(
|
||||
private readonly resourceService: ResourceService,
|
||||
@InjectQueue('file-queue') private fileQueue: Queue,
|
||||
) {}
|
||||
onModuleInit() {
|
||||
this.initializeTusServer();
|
||||
this.setupTusEventHandlers();
|
||||
}
|
||||
private initializeTusServer() {
|
||||
this.tusServer = new Server({
|
||||
namingFunction(req, metadata) {
|
||||
const safeFilename = slugify(metadata.filename);
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const uniqueId = nanoid(10);
|
||||
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
|
||||
},
|
||||
path: '/upload',
|
||||
datastore: new FileStore({
|
||||
directory: FILE_UPLOAD_CONFIG.directory,
|
||||
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
|
||||
}),
|
||||
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
|
||||
postReceiveInterval: 1000,
|
||||
getFileIdFromRequest: (req, lastPath) => {
|
||||
const match = req.url.match(/\/upload\/(.+)/);
|
||||
return match ? match[1] : lastPath;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private setupTusEventHandlers() {
|
||||
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this));
|
||||
this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this));
|
||||
private setupTusEventHandlers() {
|
||||
this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this));
|
||||
this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this));
|
||||
}
|
||||
private getFileId(uploadId: string) {
|
||||
return uploadId.replace(/\/[^/]+$/, '');
|
||||
}
|
||||
private async handleUploadCreate(
|
||||
req: Request,
|
||||
res: Response,
|
||||
upload: Upload,
|
||||
url: string,
|
||||
) {
|
||||
try {
|
||||
const fileId = this.getFileId(upload.id);
|
||||
// const filename = upload.metadata.filename;
|
||||
await this.resourceService.create({
|
||||
data: {
|
||||
title: getFilenameWithoutExt(upload.metadata.filename),
|
||||
fileId, // 移除最后的文件名
|
||||
url: upload.id,
|
||||
metadata: upload.metadata,
|
||||
status: ResourceStatus.UPLOADING,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create resource during upload', error);
|
||||
}
|
||||
private getFileId(uploadId: string) {
|
||||
return uploadId.replace(/\/[^/]+$/, '')
|
||||
}
|
||||
private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) {
|
||||
try {
|
||||
}
|
||||
|
||||
const fileId = this.getFileId(upload.id)
|
||||
const filename = upload.metadata.filename
|
||||
await this.resourceService.create({
|
||||
data: {
|
||||
title: getFilenameWithoutExt(upload.metadata.filename),
|
||||
fileId, // 移除最后的文件名
|
||||
url: upload.id,
|
||||
metadata: upload.metadata,
|
||||
status: ResourceStatus.UPLOADING
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create resource during upload', error);
|
||||
}
|
||||
private async handleUploadFinish(
|
||||
req: Request,
|
||||
res: Response,
|
||||
upload: Upload,
|
||||
) {
|
||||
try {
|
||||
console.log('upload.id', upload.id);
|
||||
console.log('fileId', this.getFileId(upload.id));
|
||||
const resource = await this.resourceService.update({
|
||||
where: { fileId: this.getFileId(upload.id) },
|
||||
data: { status: ResourceStatus.UPLOADED },
|
||||
});
|
||||
this.fileQueue.add(
|
||||
QueueJobType.FILE_PROCESS,
|
||||
{ resource },
|
||||
{ jobId: resource.id },
|
||||
);
|
||||
this.logger.log(`Upload finished ${resource.url}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update resource after upload', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUploadFinish(req: Request, res: Response, upload: Upload) {
|
||||
try {
|
||||
const resource = await this.resourceService.update({
|
||||
where: { fileId: this.getFileId(upload.id) },
|
||||
data: { status: ResourceStatus.UPLOADED }
|
||||
});
|
||||
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id })
|
||||
this.logger.log(`Upload finished ${resource.url}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update resource after upload', error);
|
||||
}
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanupExpiredUploads() {
|
||||
try {
|
||||
// Delete incomplete uploads older than 24 hours
|
||||
const deletedResources = await db.resource.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
|
||||
},
|
||||
status: ResourceStatus.UPLOADING,
|
||||
},
|
||||
});
|
||||
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
|
||||
this.logger.log(
|
||||
`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Expired uploads cleanup failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async cleanupExpiredUploads() {
|
||||
try {
|
||||
// Delete incomplete uploads older than 24 hours
|
||||
const deletedResources = await db.resource.deleteMany({
|
||||
where: {
|
||||
createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) },
|
||||
status: ResourceStatus.UPLOADING
|
||||
}
|
||||
});
|
||||
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
|
||||
this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`);
|
||||
} catch (error) {
|
||||
this.logger.error('Expired uploads cleanup failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleTus(req: Request, res: Response) {
|
||||
|
||||
return this.tusServer.handle(req, res);
|
||||
}
|
||||
}
|
||||
async handleTus(req: Request, res: Response) {
|
||||
return this.tusServer.handle(req, res);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
export interface UploadCompleteEvent {
|
||||
identifier: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
integrityVerified: boolean;
|
||||
identifier: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
integrityVerified: boolean;
|
||||
}
|
||||
|
||||
export type UploadEvent = {
|
||||
uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean };
|
||||
uploadComplete: UploadCompleteEvent
|
||||
uploadError: { identifier: string; error: string, filename: string };
|
||||
}
|
||||
uploadStart: {
|
||||
identifier: string;
|
||||
filename: string;
|
||||
totalSize: number;
|
||||
resuming?: boolean;
|
||||
};
|
||||
uploadComplete: UploadCompleteEvent;
|
||||
uploadError: { identifier: string; error: string; filename: string };
|
||||
};
|
||||
export interface UploadLock {
|
||||
clientId: string;
|
||||
timestamp: number;
|
||||
clientId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
// 添加重试机制,处理临时网络问题
|
||||
// 实现定期清理过期的临时文件
|
||||
|
@ -21,4 +26,4 @@ export interface UploadLock {
|
|||
// 实现上传进度持久化,支持服务重启后恢复
|
||||
// 添加并发限制,防止系统资源耗尽
|
||||
// 实现文件去重功能,避免重复上传
|
||||
// 添加日志记录和监控机制
|
||||
// 添加日志记录和监控机制
|
||||
|
|
|
@ -1,55 +1,54 @@
|
|||
import {
|
||||
Controller,
|
||||
All,
|
||||
Req,
|
||||
Res,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
Controller,
|
||||
All,
|
||||
Req,
|
||||
Res,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from "express"
|
||||
import { Request, Response } from 'express';
|
||||
import { TusService } from './tus.service';
|
||||
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
constructor(private readonly tusService: TusService) { }
|
||||
// @Post()
|
||||
// async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
// return this.tusService.handleTus(req, res);
|
||||
// }
|
||||
constructor(private readonly tusService: TusService) {}
|
||||
// @Post()
|
||||
// async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
// return this.tusService.handleTus(req, res);
|
||||
// }
|
||||
|
||||
@Options()
|
||||
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
|
||||
@Options()
|
||||
async handleOptions(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Head()
|
||||
async handleHead(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
|
||||
@Head()
|
||||
async handleHead(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Post()
|
||||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Get('/*')
|
||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Get("/*")
|
||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Patch('/*')
|
||||
async handlePatch(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
|
||||
@Patch("/*")
|
||||
async handlePatch(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
|
||||
// Keeping the catch-all method as a fallback
|
||||
@All()
|
||||
async handleUpload(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
}
|
||||
// Keeping the catch-all method as a fallback
|
||||
@All()
|
||||
async handleUpload(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,13 +5,13 @@ import { TusService } from './tus.service';
|
|||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
||||
}),
|
||||
ResourceModule
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [TusService],
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
|
||||
}),
|
||||
ResourceModule,
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [TusService],
|
||||
})
|
||||
export class UploadModule { }
|
||||
export class UploadModule {}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export function extractFileIdFromNginxUrl(url: string) {
|
||||
const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
|
||||
import { createHash } from 'crypto';
|
||||
import { createReadStream } from 'fs';
|
||||
import path from 'path';
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
export function getFilenameWithoutExt(filename: string) {
|
||||
return filename ? filename.replace(/\.[^/.]+$/, '') : filename;
|
||||
return filename ? filename.replace(/\.[^/.]+$/, '') : filename;
|
||||
}
|
||||
/**
|
||||
* 计算文件的 SHA-256 哈希值
|
||||
|
@ -13,31 +12,31 @@ export function getFilenameWithoutExt(filename: string) {
|
|||
* @returns Promise<string> 返回文件的哈希值(十六进制字符串)
|
||||
*/
|
||||
export async function calculateFileHash(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建一个 SHA-256 哈希对象
|
||||
const hash = createHash('sha256');
|
||||
// 创建文件读取流
|
||||
const readStream = createReadStream(filePath);
|
||||
// 处理读取错误
|
||||
readStream.on('error', (error) => {
|
||||
reject(new Error(`Failed to read file: ${error.message}`));
|
||||
});
|
||||
// 处理哈希计算错误
|
||||
hash.on('error', (error) => {
|
||||
reject(new Error(`Failed to calculate hash: ${error.message}`));
|
||||
});
|
||||
// 流式处理文件内容
|
||||
readStream
|
||||
.pipe(hash)
|
||||
.on('finish', () => {
|
||||
// 获取最终的哈希值(十六进制格式)
|
||||
const fileHash = hash.digest('hex');
|
||||
resolve(fileHash);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(new Error(`Hash calculation failed: ${error.message}`));
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
// 创建一个 SHA-256 哈希对象
|
||||
const hash = createHash('sha256');
|
||||
// 创建文件读取流
|
||||
const readStream = createReadStream(filePath);
|
||||
// 处理读取错误
|
||||
readStream.on('error', (error) => {
|
||||
reject(new Error(`Failed to read file: ${error.message}`));
|
||||
});
|
||||
// 处理哈希计算错误
|
||||
hash.on('error', (error) => {
|
||||
reject(new Error(`Failed to calculate hash: ${error.message}`));
|
||||
});
|
||||
// 流式处理文件内容
|
||||
readStream
|
||||
.pipe(hash)
|
||||
.on('finish', () => {
|
||||
// 获取最终的哈希值(十六进制格式)
|
||||
const fileHash = hash.digest('hex');
|
||||
resolve(fileHash);
|
||||
})
|
||||
.on('error', (error) => {
|
||||
reject(new Error(`Hash calculation failed: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -46,9 +45,9 @@ export async function calculateFileHash(filePath: string): Promise<string> {
|
|||
* @returns string 返回 Buffer 的哈希值(十六进制字符串)
|
||||
*/
|
||||
export function calculateBufferHash(buffer: Buffer): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(buffer);
|
||||
return hash.digest('hex');
|
||||
const hash = createHash('sha256');
|
||||
hash.update(buffer);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -57,11 +56,11 @@ export function calculateBufferHash(buffer: Buffer): string {
|
|||
* @returns string 返回字符串的哈希值(十六进制字符串)
|
||||
*/
|
||||
export function calculateStringHash(content: string): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(content);
|
||||
return hash.digest('hex');
|
||||
const hash = createHash('sha256');
|
||||
hash.update(content);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
export const getUploadFilePath = (fileId: string): string => {
|
||||
const uploadDirectory = process.env.UPLOAD_DIR;
|
||||
return path.join(uploadDirectory, fileId);
|
||||
};
|
||||
const uploadDirectory = process.env.UPLOAD_DIR;
|
||||
return path.join(uploadDirectory, fileId);
|
||||
};
|
||||
|
|
|
@ -3,24 +3,24 @@ import * as Minio from 'minio';
|
|||
|
||||
@Injectable()
|
||||
export class MinioService {
|
||||
private readonly logger = new Logger(MinioService.name)
|
||||
private readonly minioClient: Minio.Client;
|
||||
constructor() {
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_HOST || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
|
||||
});
|
||||
}
|
||||
async createBucket(bucketName: string): Promise<void> {
|
||||
const exists = await this.minioClient.bucketExists(bucketName);
|
||||
if (!exists) {
|
||||
await this.minioClient.makeBucket(bucketName, '');
|
||||
this.logger.log(`Bucket ${bucketName} created successfully.`);
|
||||
} else {
|
||||
this.logger.log(`Bucket ${bucketName} already exists.`);
|
||||
}
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private readonly minioClient: Minio.Client;
|
||||
constructor() {
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_HOST || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
|
||||
});
|
||||
}
|
||||
async createBucket(bucketName: string): Promise<void> {
|
||||
const exists = await this.minioClient.bucketExists(bucketName);
|
||||
if (!exists) {
|
||||
await this.minioClient.makeBucket(bucketName, '');
|
||||
this.logger.log(`Bucket ${bucketName} created successfully.`);
|
||||
} else {
|
||||
this.logger.log(`Bucket ${bucketName} already exists.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,4 +89,4 @@
|
|||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^5.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import CourseDetail from "@web/src/components/models/course/detail/CourseDetail";
|
||||
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export function CourseDetailPage() {
|
||||
|
|
|
@ -52,7 +52,7 @@ export default function InstructorCoursesPage() {
|
|||
renderItem={(course) => (
|
||||
<CourseCard
|
||||
onClick={() => {
|
||||
navigate(`/course/${course.id}/manage`, {
|
||||
navigate(`/course/${course.id}/editor`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -1,84 +1,25 @@
|
|||
import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor';
|
||||
import MindMapEditor from '@web/src/components/presentation/mind-map';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import * as tus from 'tus-js-client';
|
||||
import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor";
|
||||
import FileUploader from "@web/src/components/common/uploader/FileUploader";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import * as tus from "tus-js-client";
|
||||
interface TusUploadProps {
|
||||
onSuccess?: (response: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onSuccess?: (response: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
const TusUploader: React.FC<TusUploadProps> = ({
|
||||
onSuccess,
|
||||
onError
|
||||
}) => {
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [isUploading, setIsUploading] = useState<boolean>(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const handleFileUpload = useCallback((file: File) => {
|
||||
if (!file) return;
|
||||
setIsUploading(true);
|
||||
setProgress(0);
|
||||
setUploadError(null);
|
||||
// Extract file extension
|
||||
const extension = file.name.split('.').pop() || '';
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: "http://localhost:3000/upload",
|
||||
retryDelays: [0, 1000, 3000, 5000],
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
size: file.size.toString(),
|
||||
mimeType: file.type,
|
||||
extension: extension,
|
||||
modifiedAt: new Date(file.lastModified).toISOString(),
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
||||
setProgress(Number(percentage));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsUploading(false);
|
||||
setProgress(100);
|
||||
onSuccess && onSuccess(upload);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
onError && onError(error);
|
||||
}
|
||||
});
|
||||
|
||||
upload.start();
|
||||
}, [onSuccess, onError]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='w-full' style={{ height: 800 }}>
|
||||
<GraphEditor></GraphEditor>
|
||||
</div>
|
||||
{/* <div className=' h-screen'>
|
||||
const HomePage: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
|
||||
return (
|
||||
<div>
|
||||
<FileUploader></FileUploader>
|
||||
<div className="w-full" style={{ height: 800 }}>
|
||||
<GraphEditor></GraphEditor>
|
||||
</div>
|
||||
{/* <div className=' h-screen'>
|
||||
<MindMap></MindMap>
|
||||
</div> */}
|
||||
{/* <MindMapEditor></MindMapEditor> */}
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
}}
|
||||
/>
|
||||
{isUploading && (
|
||||
<div>
|
||||
<progress value={progress} max="100" />
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
<div style={{ color: 'red' }}>
|
||||
上传错误: {uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* <MindMapEditor></MindMapEditor> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TusUploader;
|
||||
export default HomePage;
|
||||
|
|
|
@ -1,211 +1,237 @@
|
|||
import { useState, useCallback, useRef, memo } from 'react'
|
||||
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
|
||||
import * as tus from 'tus-js-client'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { toast } from 'react-hot-toast'
|
||||
// FileUploader.tsx
|
||||
import React, { useRef, memo, useState } from "react";
|
||||
import {
|
||||
CloudArrowUpIcon,
|
||||
XMarkIcon,
|
||||
DocumentIcon,
|
||||
ExclamationCircleIcon,
|
||||
CheckCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
|
||||
interface FileUploaderProps {
|
||||
endpoint?: string
|
||||
onSuccess?: (url: string) => void
|
||||
onError?: (error: Error) => void
|
||||
maxSize?: number
|
||||
allowedTypes?: string[]
|
||||
placeholder?: string
|
||||
endpoint?: string;
|
||||
onSuccess?: (url: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
maxSize?: number;
|
||||
allowedTypes?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const FileItem = memo(({ file, progress, onRemove }: {
|
||||
file: File
|
||||
progress?: number
|
||||
onRemove: (name: string) => void
|
||||
}) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
|
||||
<button
|
||||
onClick={() => onRemove(file.name)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{progress !== undefined && (
|
||||
<div className="mt-2">
|
||||
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-1.5 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
interface FileItemProps {
|
||||
file: File;
|
||||
progress?: number;
|
||||
onRemove: (name: string) => void;
|
||||
isUploaded: boolean;
|
||||
}
|
||||
|
||||
export default function FileUploader({
|
||||
endpoint='',
|
||||
onSuccess,
|
||||
onError,
|
||||
maxSize = 100,
|
||||
placeholder = '点击或拖拽文件到这里上传',
|
||||
allowedTypes = ['*/*']
|
||||
}: FileUploaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [progress, setProgress] = useState<{ [key: string]: number }>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const FileItem: React.FC<FileItemProps> = memo(
|
||||
({ file, progress, onRemove, isUploaded }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
|
||||
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
|
||||
{file.name}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onRemove(file.name)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
|
||||
aria-label={`Remove ${file.name}`}>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
{!isUploaded && progress !== undefined && (
|
||||
<div className="mt-2">
|
||||
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-1.5 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isUploaded && (
|
||||
<div className="mt-2 flex items-center text-green-500">
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
<span className="text-xs">上传完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
toast.error(error.message)
|
||||
onError?.(error)
|
||||
}, [onError])
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
endpoint = "",
|
||||
onSuccess,
|
||||
onError,
|
||||
maxSize = 100,
|
||||
placeholder = "点击或拖拽文件到这里上传",
|
||||
allowedTypes = ["*/*"],
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<
|
||||
Array<{ file: File; isUploaded: boolean }>
|
||||
>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
setIsDragging(true)
|
||||
} else if (e.type === 'dragleave') {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}, [])
|
||||
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||
useTusUpload();
|
||||
|
||||
const validateFile = useCallback((file: File) => {
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
throw new Error(`文件大小不能超过 ${maxSize}MB`)
|
||||
}
|
||||
if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) {
|
||||
throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`)
|
||||
}
|
||||
}, [maxSize, allowedTypes])
|
||||
const handleError = (error: Error) => {
|
||||
toast.error(error.message);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
try {
|
||||
validateFile(file)
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setIsDragging(true);
|
||||
} else if (e.type === "dragleave") {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint,
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type
|
||||
},
|
||||
onError: handleError,
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2)
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
[file.name]: parseFloat(percentage)
|
||||
}))
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess?.(upload.url || '')
|
||||
setProgress(prev => {
|
||||
const newProgress = { ...prev }
|
||||
delete newProgress[file.name]
|
||||
return newProgress
|
||||
})
|
||||
}
|
||||
})
|
||||
const validateFile = (file: File) => {
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
throw new Error(`文件大小不能超过 ${maxSize}MB`);
|
||||
}
|
||||
if (
|
||||
!allowedTypes.includes("*/*") &&
|
||||
!allowedTypes.includes(file.type)
|
||||
) {
|
||||
throw new Error(
|
||||
`不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
upload.start()
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
}
|
||||
const uploadFile = (file: File) => {
|
||||
try {
|
||||
validateFile(file);
|
||||
handleFileUpload(
|
||||
file,
|
||||
(upload) => {
|
||||
onSuccess?.(upload.url || "");
|
||||
setFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.file.name === file.name
|
||||
? { ...f, isUploaded: true }
|
||||
: f
|
||||
)
|
||||
);
|
||||
},
|
||||
handleError
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
setFiles(prev => [...prev, ...droppedFiles])
|
||||
droppedFiles.forEach(uploadFile)
|
||||
}, [])
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
...droppedFiles.map((file) => ({ file, isUploaded: false })),
|
||||
]);
|
||||
droppedFiles.forEach(uploadFile);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
setFiles(prev => [...prev, ...selectedFiles])
|
||||
selectedFiles.forEach(uploadFile)
|
||||
}
|
||||
}
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
setFiles((prev) => [
|
||||
...prev,
|
||||
...selectedFiles.map((file) => ({ file, isUploaded: false })),
|
||||
]);
|
||||
selectedFiles.forEach(uploadFile);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (fileName: string) => {
|
||||
setFiles(prev => prev.filter(file => file.name !== fileName))
|
||||
setProgress(prev => {
|
||||
const newProgress = { ...prev }
|
||||
delete newProgress[fileName]
|
||||
return newProgress
|
||||
})
|
||||
}
|
||||
const removeFile = (fileName: string) => {
|
||||
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<motion.div
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
|
||||
${isDragging
|
||||
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
|
||||
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="文件上传区域"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
accept={allowedTypes.join(',')}
|
||||
/>
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<motion.div
|
||||
animate={{ y: isDragging ? -10 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||
>
|
||||
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
|
||||
</motion.div>
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500">{placeholder}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
<ExclamationCircleIcon className="w-4 h-4" />
|
||||
支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
relative flex flex-col items-center justify-center w-full h-32
|
||||
border-2 border-dashed rounded-lg cursor-pointer
|
||||
transition-colors duration-200 ease-in-out
|
||||
${
|
||||
isDragging
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-300 hover:border-blue-500"
|
||||
}
|
||||
`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
accept={allowedTypes.join(",")}
|
||||
className="hidden"
|
||||
/>
|
||||
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
|
||||
<p className="text-blue-500 font-medium">
|
||||
释放文件以上传
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<div className="space-y-3">
|
||||
{files.map(file => (
|
||||
<FileItem
|
||||
key={file.name}
|
||||
file={file}
|
||||
progress={progress[file.name]}
|
||||
onRemove={removeFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<AnimatePresence>
|
||||
<div className="space-y-3">
|
||||
{files.map(({ file, isUploaded }) => (
|
||||
<FileItem
|
||||
key={file.name}
|
||||
file={file}
|
||||
progress={isUploaded ? 100 : progress}
|
||||
onRemove={removeFile}
|
||||
isUploaded={isUploaded}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
{uploadError && (
|
||||
<div className="flex items-center text-red-500 text-sm">
|
||||
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
|
||||
<span>{uploadError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUploader;
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
import { useTusUpload } from "@web/src/hooks/useTusUpload"; // 假设 useTusUpload 在同一个目录下
|
||||
import * as tus from "tus-js-client";
|
||||
interface TusUploadProps {
|
||||
onSuccess?: (upload: tus.Upload) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const TusUploader: React.FC<TusUploadProps> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { progress, isUploading, uploadError, handleFileUpload } =
|
||||
useTusUpload();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file, onSuccess, onError);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isUploading && (
|
||||
<div>
|
||||
<progress value={progress} max="100" />
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<div style={{ color: "red" }}>上传错误: {uploadError}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TusUploader;
|
|
@ -2,6 +2,8 @@ export const env: {
|
|||
APP_NAME: string;
|
||||
SERVER_IP: string;
|
||||
VERSION: string;
|
||||
UOLOAD_PORT: string;
|
||||
SERVER_PORT: string;
|
||||
} = {
|
||||
APP_NAME: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_APP_NAME
|
||||
|
@ -9,6 +11,12 @@ export const env: {
|
|||
SERVER_IP: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_SERVER_IP
|
||||
: import.meta.env.VITE_APP_SERVER_IP,
|
||||
UOLOAD_PORT: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_UOLOAD_PORT
|
||||
: import.meta.env.VITE_APP_UOLOAD_PORT,
|
||||
SERVER_PORT: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_SERVER_PORT
|
||||
: import.meta.env.VITE_APP_SERVER_PORT,
|
||||
VERSION: import.meta.env.PROD
|
||||
? (window as any).env.VITE_APP_VERSION
|
||||
: import.meta.env.VITE_APP_VERSION,
|
||||
|
|
|
@ -0,0 +1,125 @@
|
|||
import { useState } from "react";
|
||||
import * as tus from "tus-js-client";
|
||||
import { env } from "../env";
|
||||
import { getCompressedImageUrl } from "@nice/utils";
|
||||
// useTusUpload.ts
|
||||
interface UploadProgress {
|
||||
fileId: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
compressedUrl: string;
|
||||
url: string;
|
||||
fileId: string;
|
||||
}
|
||||
|
||||
export function useTusUpload() {
|
||||
const [uploadProgress, setUploadProgress] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const getFileId = (url: string) => {
|
||||
const parts = url.split("/");
|
||||
const uploadIndex = parts.findIndex((part) => part === "upload");
|
||||
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||
throw new Error("Invalid upload URL format");
|
||||
}
|
||||
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/");
|
||||
};
|
||||
const getResourceUrl = (url: string) => {
|
||||
const parts = url.split("/");
|
||||
const uploadIndex = parts.findIndex((part) => part === "upload");
|
||||
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||
throw new Error("Invalid upload URL format");
|
||||
}
|
||||
const resUrl = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
|
||||
|
||||
return resUrl;
|
||||
};
|
||||
const handleFileUpload = async (
|
||||
file: File,
|
||||
onSuccess: (result: UploadResult) => void,
|
||||
onError: (error: Error) => void,
|
||||
fileKey: string // 添加文件唯一标识
|
||||
) => {
|
||||
// if (!file || !file.name || !file.type) {
|
||||
// const error = new Error("不可上传该类型文件");
|
||||
// setUploadError(error.message);
|
||||
// onError(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 }));
|
||||
setUploadError(null);
|
||||
|
||||
try {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`,
|
||||
retryDelays: [0, 1000, 3000, 5000],
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
size: file.size as any,
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const progress = Number(
|
||||
((bytesUploaded / bytesTotal) * 100).toFixed(2)
|
||||
);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: progress,
|
||||
}));
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
try {
|
||||
if (upload.url) {
|
||||
const fileId = getFileId(upload.url);
|
||||
const url = getResourceUrl(upload.url);
|
||||
setIsUploading(false);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: 100,
|
||||
}));
|
||||
onSuccess({
|
||||
compressedUrl: getCompressedImageUrl(url),
|
||||
url,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Unknown error");
|
||||
setIsUploading(false);
|
||||
setUploadError(err.message);
|
||||
onError(err);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error("Upload failed");
|
||||
setIsUploading(false);
|
||||
setUploadError(err.message);
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
uploadError,
|
||||
handleFileUpload,
|
||||
};
|
||||
}
|
|
@ -1,56 +1,76 @@
|
|||
import { api } from "../trpc";
|
||||
|
||||
export function useCourse() {
|
||||
const utils = api.useUtils();
|
||||
return {
|
||||
// Queries
|
||||
findMany: api.course.findMany.useQuery,
|
||||
findFirst: api.course.findFirst.useQuery,
|
||||
findManyWithCursor: api.course.findManyWithCursor.useQuery,
|
||||
// 定义返回类型
|
||||
type UseCourseReturn = {
|
||||
// Queries
|
||||
findMany: typeof api.course.findMany.useQuery;
|
||||
findFirst: typeof api.course.findFirst.useQuery;
|
||||
findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
|
||||
|
||||
// Mutations
|
||||
create: api.course.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.invalidate()
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
}),
|
||||
update: api.course.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
}),
|
||||
createMany: api.course.createMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
}),
|
||||
deleteMany: api.course.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
}),
|
||||
softDeleteByIds: api.course.softDeleteByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
}),
|
||||
updateOrder: api.course.updateOrder.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate()
|
||||
},
|
||||
})
|
||||
};
|
||||
}
|
||||
// Mutations
|
||||
create: ReturnType<any>;
|
||||
// create: ReturnType<typeof api.course.create.useMutation>;
|
||||
update: ReturnType<any>;
|
||||
// update: ReturnType<typeof api.course.update.useMutation>;
|
||||
createMany: ReturnType<typeof api.course.createMany.useMutation>;
|
||||
deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
|
||||
softDeleteByIds: ReturnType<any>;
|
||||
// softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
|
||||
updateOrder: ReturnType<any>;
|
||||
// updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
|
||||
};
|
||||
|
||||
export function useCourse(): UseCourseReturn {
|
||||
const utils = api.useUtils();
|
||||
return {
|
||||
// Queries
|
||||
findMany: api.course.findMany.useQuery,
|
||||
findFirst: api.course.findFirst.useQuery,
|
||||
findManyWithCursor: api.course.findManyWithCursor.useQuery,
|
||||
|
||||
// Mutations
|
||||
create: api.course.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.invalidate();
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
update: api.course.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
createMany: api.course.createMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
deleteMany: api.course.deleteMany.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
softDeleteByIds: api.course.softDeleteByIds.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
updateOrder: api.course.updateOrder.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.course.findMany.invalidate();
|
||||
utils.course.findManyWithCursor.invalidate();
|
||||
utils.course.findManyWithPagination.invalidate();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -50,35 +50,6 @@ export function useDepartment() {
|
|||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
// const getTreeData = () => {
|
||||
// const uniqueData: DepartmentDto[] = getCacheDataFromQuery(
|
||||
// queryClient,
|
||||
// api.department
|
||||
// );
|
||||
// const treeData: DataNode[] = buildTree(uniqueData);
|
||||
// return treeData;
|
||||
// };
|
||||
// const getTreeData = () => {
|
||||
// const cacheArray = queryClient.getQueriesData({
|
||||
// queryKey: getQueryKey(api.department.getChildren),
|
||||
// });
|
||||
// const data: DepartmentDto[] = cacheArray
|
||||
// .flatMap((cache) => cache.slice(1))
|
||||
// .flat()
|
||||
// .filter((item) => item !== undefined) as any;
|
||||
// const uniqueDataMap = new Map<string, DepartmentDto>();
|
||||
|
||||
// data?.forEach((item) => {
|
||||
// if (item && item.id) {
|
||||
// uniqueDataMap.set(item.id, item);
|
||||
// }
|
||||
// });
|
||||
// // Convert the Map back to an array
|
||||
// const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values());
|
||||
// const treeData: DataNode[] = buildTree(uniqueData);
|
||||
// return treeData;
|
||||
// };
|
||||
const getDept = <T = DepartmentDto>(key: string) => {
|
||||
return findQueryData<T>(queryClient, api.department, key);
|
||||
};
|
||||
|
|
|
@ -1,28 +1,20 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"esnext"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "node",
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
],
|
||||
}
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["dom", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"moduleResolution": "node",
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
@ -1,34 +1,35 @@
|
|||
{
|
||||
"name": "@nice/common",
|
||||
"version": "1.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"generate": "pnpm prisma generate",
|
||||
"build": "pnpm generate && tsup",
|
||||
"dev": "pnpm generate && tsup --watch ",
|
||||
"studio": "pnpm prisma studio",
|
||||
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
|
||||
"postinstall": "pnpm generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.17.0",
|
||||
"prisma": "5.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.23.8",
|
||||
"yjs": "^13.6.20",
|
||||
"lib0": "^0.2.98"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.3.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.5.4",
|
||||
"concurrently": "^8.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"rimraf": "^6.0.1"
|
||||
}
|
||||
}
|
||||
"name": "@nice/common",
|
||||
"version": "1.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"generate": "pnpm prisma generate",
|
||||
"build": "pnpm generate && tsup",
|
||||
"dev": "pnpm generate && tsup --watch ",
|
||||
"dev-nowatch": "pnpm generate && tsup --no-watch ",
|
||||
"studio": "pnpm prisma studio",
|
||||
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
|
||||
"postinstall": "pnpm generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.17.0",
|
||||
"prisma": "5.17.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.23.8",
|
||||
"yjs": "^13.6.20",
|
||||
"lib0": "^0.2.98"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.3.1",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.5.4",
|
||||
"concurrently": "^8.0.0",
|
||||
"tsup": "^8.3.5",
|
||||
"rimraf": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,16 +74,16 @@ export const courseDetailSelect: Prisma.CourseSelect = {
|
|||
level: true,
|
||||
requirements: true,
|
||||
objectives: true,
|
||||
skills: true,
|
||||
audiences: true,
|
||||
totalDuration: true,
|
||||
totalLectures: true,
|
||||
averageRating: true,
|
||||
numberOfReviews: true,
|
||||
numberOfStudents: true,
|
||||
completionRate: true,
|
||||
// skills: true,
|
||||
// audiences: true,
|
||||
// totalDuration: true,
|
||||
// totalLectures: true,
|
||||
// averageRating: true,
|
||||
// numberOfReviews: true,
|
||||
// numberOfStudents: true,
|
||||
// completionRate: true,
|
||||
status: true,
|
||||
isFeatured: true,
|
||||
// isFeatured: true,
|
||||
createdAt: true,
|
||||
publishedAt: true,
|
||||
// 关联表选择
|
||||
|
|
|
@ -1,10 +1,18 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['cjs', 'esm'],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
dts: true
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
dts: true,
|
||||
// watch 可以是布尔值或字符串数组
|
||||
watch: [
|
||||
"src/**/*.ts",
|
||||
"!src/**/*.test.ts",
|
||||
"!src/**/*.spec.ts",
|
||||
"!node_modules/**",
|
||||
"!dist/**",
|
||||
],
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"dev-static": "tsup --no-watch",
|
||||
"clean": "rimraf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
|
|
@ -4,20 +4,38 @@
|
|||
* @returns 唯一ID字符串
|
||||
*/
|
||||
export function generateUniqueId(prefix?: string): string {
|
||||
// 获取当前时间戳
|
||||
const timestamp = Date.now();
|
||||
// 获取当前时间戳
|
||||
const timestamp = Date.now();
|
||||
|
||||
// 生成随机数部分
|
||||
const randomPart = Math.random().toString(36).substring(2, 8);
|
||||
// 生成随机数部分
|
||||
const randomPart = Math.random().toString(36).substring(2, 8);
|
||||
|
||||
// 获取环境特定的额外随机性
|
||||
const environmentPart = typeof window !== 'undefined'
|
||||
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
|
||||
: require('crypto').randomBytes(4).toString('hex');
|
||||
// 获取环境特定的额外随机性
|
||||
const environmentPart =
|
||||
typeof window !== "undefined"
|
||||
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
|
||||
: require("crypto").randomBytes(4).toString("hex");
|
||||
|
||||
// 组合所有部分
|
||||
const uniquePart = `${timestamp}${randomPart}${environmentPart}`;
|
||||
// 组合所有部分
|
||||
const uniquePart = `${timestamp}${randomPart}${environmentPart}`;
|
||||
|
||||
// 如果提供了前缀,则添加前缀
|
||||
return prefix ? `${prefix}_${uniquePart}` : uniquePart;
|
||||
}
|
||||
// 如果提供了前缀,则添加前缀
|
||||
return prefix ? `${prefix}_${uniquePart}` : uniquePart;
|
||||
}
|
||||
export const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
// 压缩图片路径生成函数
|
||||
export const getCompressedImageUrl = (originalUrl: string): string => {
|
||||
if (!originalUrl) {
|
||||
return originalUrl;
|
||||
}
|
||||
const cleanUrl = originalUrl.split(/[?#]/)[0]; // 移除查询参数和哈希
|
||||
const lastSlashIndex = cleanUrl.lastIndexOf("/");
|
||||
return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`;
|
||||
};
|
||||
export * from "./types";
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export type NonVoid<T> = T extends void ? never : T;
|
|
@ -296,6 +296,9 @@ importers:
|
|||
'@nice/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/ui
|
||||
'@nice/utils':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/utils
|
||||
'@tanstack/query-async-storage-persister':
|
||||
specifier: ^5.51.9
|
||||
version: 5.62.16
|
||||
|
|
Loading…
Reference in New Issue