diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index e396a51..de67161 100755 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -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) { diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index 82a65b9..f7f679c 100755 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -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 { 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): Promise { + async signIn( + data: z.infer, + ): Promise { 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) { @@ -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: '注销成功' }; } - -} \ No newline at end of file +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1875a5f..8b82ed1 100755 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -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(); diff --git a/apps/server/src/models/base/row-model.service.ts b/apps/server/src/models/base/row-model.service.ts index 406beab..a0cbb6f 100644 --- a/apps/server/src/models/base/row-model.service.ts +++ b/apps/server/src/models/base/row-model.service.ts @@ -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 = 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 { - return row; + private keywords: Set = 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 { + 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 { + 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 { + 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 { - 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 { + const results = await this.getRowsWithFilters(condition, staff); + return results[0]; + } + private async getMultipleRows( + condition: LogicalCondition, + staff?: UserProfile, + ): Promise { + return this.getRowsWithFilters(condition, staff); + } - async getRowByIds(options: GetRowOptions): Promise { - 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 { + 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 { - const results = await this.getRowsWithFilters(condition, staff) - return results[0] - } - private async getMultipleRows(condition: LogicalCondition, staff?: UserProfile): Promise { - return this.getRowsWithFilters(condition, staff); - } - - private async getRowsWithFilters(condition: LogicalCondition, staff?: UserProfile): Promise { - 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', + }; + } } - diff --git a/apps/server/src/models/course/course.router.ts b/apps/server/src/models/course/course.router.ts index f08d35c..4ed98c4 100644 --- a/apps/server/src/models/course/course.router.ts +++ b/apps/server/src/models/course/course.router.ts @@ -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 = z.any() -const CourseUpdateArgsSchema: ZodType = z.any() -const CourseCreateManyInputSchema: ZodType = z.any() -const CourseDeleteManyArgsSchema: ZodType = z.any() -const CourseFindManyArgsSchema: ZodType = z.any() -const CourseFindFirstArgsSchema: ZodType = z.any() -const CourseWhereInputSchema: ZodType = z.any() -const CourseSelectSchema: ZodType = z.any() +const CourseCreateArgsSchema: ZodType = z.any(); +const CourseUpdateArgsSchema: ZodType = z.any(); +const CourseCreateManyInputSchema: ZodType = + z.any(); +const CourseDeleteManyArgsSchema: ZodType = + z.any(); +const CourseFindManyArgsSchema: ZodType = z.any(); +const CourseFindFirstArgsSchema: ZodType = z.any(); +const CourseWhereInputSchema: ZodType = z.any(); +const CourseSelectSchema: ZodType = 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); + }), + }); } diff --git a/apps/server/src/models/course/utils.ts b/apps/server/src/models/course/utils.ts index 9d2092c..9cd98c7 100644 --- a/apps/server/src/models/course/utils.ts +++ b/apps/server/src/models/course/utils.ts @@ -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, + }, + }); } - diff --git a/apps/server/src/models/lecture/utils.ts b/apps/server/src/models/lecture/utils.ts index 008bc87..f5216f8 100644 --- a/apps/server/src/models/lecture/utils.ts +++ b/apps/server/src/models/lecture/utils.ts @@ -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 - } - }); -} \ No newline at end of file + await db.course.update({ + where: { id: courseId }, + data: { + //totalLectures: courseStats._count._all, + //totalDuration: courseStats._sum.duration || 0, + }, + }); +} diff --git a/apps/server/src/models/term/term.row.service.ts b/apps/server/src/models/term/term.row.service.ts index 65ba56e..2b20e62 100644 --- a/apps/server/src/models/term/term.row.service.ts +++ b/apps/server/src/models/term/term.row.service.ts @@ -17,20 +17,22 @@ export class TermRowService extends RowCacheService { createUnGroupingRowSelect( requset: z.infer, ): 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; } - } diff --git a/apps/server/src/queue/postprocess/postprocess.service.ts b/apps/server/src/queue/postprocess/postprocess.service.ts index ddbc821..eef571b 100644 --- a/apps/server/src/queue/postprocess/postprocess.service.ts +++ b/apps/server/src/queue/postprocess/postprocess.service.ts @@ -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()}`; } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/queue.module.ts b/apps/server/src/queue/queue.module.ts index 57a1a9d..ca825eb 100755 --- a/apps/server/src/queue/queue.module.ts +++ b/apps/server/src/queue/queue.module.ts @@ -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 {} diff --git a/apps/server/src/queue/stats/stats.service.ts b/apps/server/src/queue/stats/stats.service.ts index a498704..e8ce2dc 100644 --- a/apps/server/src/queue/stats/stats.service.ts +++ b/apps/server/src/queue/stats/stats.service.ts @@ -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()}`; - } - } -} \ No newline at end of file + } +} diff --git a/apps/server/src/queue/types.ts b/apps/server/src/queue/types.ts index db4f92c..c509c71 100755 --- a/apps/server/src/queue/types.ts +++ b/apps/server/src/queue/types.ts @@ -1,4 +1,4 @@ export enum QueueJobType { - UPDATE_STATS = "update_stats", - FILE_PROCESS = "file_process" + UPDATE_STATS = 'update_stats', + FILE_PROCESS = 'file_process', } diff --git a/apps/server/src/queue/worker/file.processor.ts b/apps/server/src/queue/worker/file.processor.ts index de3d836..b416af4 100644 --- a/apps/server/src/queue/worker/file.processor.ts +++ b/apps/server/src/queue/worker/file.processor.ts @@ -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) { - 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'); } -} \ No newline at end of file + const result = await pipeline.execute(resource); + + return result; + } +} diff --git a/apps/server/src/queue/worker/processor.ts b/apps/server/src/queue/worker/processor.ts index 6574912..e5b2052 100755 --- a/apps/server/src/queue/worker/processor.ts +++ b/apps/server/src/queue/worker/processor.ts @@ -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) { - 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}`, + ); } -} \ No newline at end of file + } catch (error: any) { + logger.error( + `Error processing stats update job: ${error.message}`, + error.stack, + ); + } +} diff --git a/apps/server/src/tasks/init/init.service.ts b/apps/server/src/tasks/init/init.service.ts index 4f68e8e..8d6a955 100755 --- a/apps/server/src/tasks/init/init.service.ts +++ b/apps/server/src/tasks/init/init.service.ts @@ -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) { diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 0eea246..0173d25 100644 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -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); - } -} \ No newline at end of file + async handleTus(req: Request, res: Response) { + return this.tusServer.handle(req, res); + } +} diff --git a/apps/server/src/upload/types.ts b/apps/server/src/upload/types.ts index ef60aaa..2140ebc 100644 --- a/apps/server/src/upload/types.ts +++ b/apps/server/src/upload/types.ts @@ -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 { // 实现上传进度持久化,支持服务重启后恢复 // 添加并发限制,防止系统资源耗尽 // 实现文件去重功能,避免重复上传 -// 添加日志记录和监控机制 \ No newline at end of file +// 添加日志记录和监控机制 diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts index ff3e38b..f014c42 100644 --- a/apps/server/src/upload/upload.controller.ts +++ b/apps/server/src/upload/upload.controller.ts @@ -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); - } -} \ No newline at end of file + // Keeping the catch-all method as a fallback + @All() + async handleUpload(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } +} diff --git a/apps/server/src/upload/upload.module.ts b/apps/server/src/upload/upload.module.ts index 0a54ccb..6c8e1b0 100644 --- a/apps/server/src/upload/upload.module.ts +++ b/apps/server/src/upload/upload.module.ts @@ -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 { } \ No newline at end of file +export class UploadModule {} diff --git a/apps/server/src/upload/utils.ts b/apps/server/src/upload/utils.ts index a171d7f..a7c189f 100644 --- a/apps/server/src/upload/utils.ts +++ b/apps/server/src/upload/utils.ts @@ -1,4 +1,4 @@ export function extractFileIdFromNginxUrl(url: string) { const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/); return match ? match[1] : ''; -} \ No newline at end of file +} diff --git a/apps/server/src/utils/file.ts b/apps/server/src/utils/file.ts index 776d2ec..c24d5d9 100644 --- a/apps/server/src/utils/file.ts +++ b/apps/server/src/utils/file.ts @@ -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 返回文件的哈希值(十六进制字符串) */ export async function calculateFileHash(filePath: string): Promise { - 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 { * @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); -}; \ No newline at end of file + const uploadDirectory = process.env.UPLOAD_DIR; + return path.join(uploadDirectory, fileId); +}; diff --git a/apps/server/src/utils/minio/minio.service.ts b/apps/server/src/utils/minio/minio.service.ts index 8f402ee..e949bf6 100644 --- a/apps/server/src/utils/minio/minio.service.ts +++ b/apps/server/src/utils/minio/minio.service.ts @@ -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 { - 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 { + 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.`); } + } } diff --git a/apps/web/package.json b/apps/web/package.json index 5534079..f17956d 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -89,4 +89,4 @@ "typescript-eslint": "^8.0.1", "vite": "^5.4.1" } -} \ No newline at end of file +} diff --git a/apps/web/src/app/main/course/detail/page.tsx b/apps/web/src/app/main/course/detail/page.tsx index d4b385a..d1bf172 100644 --- a/apps/web/src/app/main/course/detail/page.tsx +++ b/apps/web/src/app/main/course/detail/page.tsx @@ -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() { diff --git a/apps/web/src/app/main/courses/instructor/page.tsx b/apps/web/src/app/main/courses/instructor/page.tsx index 92920de..032409a 100644 --- a/apps/web/src/app/main/courses/instructor/page.tsx +++ b/apps/web/src/app/main/courses/instructor/page.tsx @@ -52,7 +52,7 @@ export default function InstructorCoursesPage() { renderItem={(course) => ( { - navigate(`/course/${course.id}/manage`, { + navigate(`/course/${course.id}/editor`, { replace: true, }); }} diff --git a/apps/web/src/app/main/home/page.tsx b/apps/web/src/app/main/home/page.tsx index fa47a49..b749462 100644 --- a/apps/web/src/app/main/home/page.tsx +++ b/apps/web/src/app/main/home/page.tsx @@ -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 = ({ - onSuccess, - onError -}) => { - const [progress, setProgress] = useState(0); - const [isUploading, setIsUploading] = useState(false); - const [uploadError, setUploadError] = useState(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 ( -
-
- -
- {/*
+const HomePage: React.FC = ({ onSuccess, onError }) => { + return ( +
+ +
+ +
+ {/*
*/} - {/* */} - - { - const file = e.target.files?.[0]; - if (file) handleFileUpload(file); - }} - /> - {isUploading && ( -
- - {progress}% -
- )} - {uploadError && ( -
- 上传错误: {uploadError} -
- )} -
- ); + {/* */} +
+ ); }; -export default TusUploader; \ No newline at end of file +export default HomePage; diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx index 0d3c62c..3635063 100644 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ b/apps/web/src/components/common/uploader/FileUploader.tsx @@ -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 -}) => ( - - -
-
-

{file.name}

- -
- {progress !== undefined && ( -
-
- -
- {progress}% -
- )} -
-
-)) +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([]) - const [progress, setProgress] = useState<{ [key: string]: number }>({}) - const fileInputRef = useRef(null) +const FileItem: React.FC = memo( + ({ file, progress, onRemove, isUploaded }) => ( + + +
+
+

+ {file.name} +

+ +
+ {!isUploaded && progress !== undefined && ( +
+
+ +
+ + {progress}% + +
+ )} + {isUploaded && ( +
+ + 上传完成 +
+ )} +
+
+ ) +); - const handleError = useCallback((error: Error) => { - toast.error(error.message) - onError?.(error) - }, [onError]) +const FileUploader: React.FC = ({ + endpoint = "", + onSuccess, + onError, + maxSize = 100, + placeholder = "点击或拖拽文件到这里上传", + allowedTypes = ["*/*"], +}) => { + const [isDragging, setIsDragging] = useState(false); + const [files, setFiles] = useState< + Array<{ file: File; isUploaded: boolean }> + >([]); + const fileInputRef = useRef(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) => { - if (e.target.files) { - const selectedFiles = Array.from(e.target.files) - setFiles(prev => [...prev, ...selectedFiles]) - selectedFiles.forEach(uploadFile) - } - } + const handleFileSelect = (e: React.ChangeEvent) => { + 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 ( -
- fileInputRef.current?.click()} - aria-label="文件上传区域" - > - + const handleClick = () => { + fileInputRef.current?.click(); + }; -
- - - -
-

{placeholder}

-
-

- - 支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB -

-
-
+ return ( +
+
+ + +

{placeholder}

+ {isDragging && ( +
+

+ 释放文件以上传 +

+
+ )} +
- -
- {files.map(file => ( - - ))} -
-
-
- ) -} \ No newline at end of file + +
+ {files.map(({ file, isUploaded }) => ( + + ))} +
+
+ + {uploadError && ( +
+ + {uploadError} +
+ )} +
+ ); +}; + +export default FileUploader; diff --git a/apps/web/src/components/presentation/TusUploader.tsx b/apps/web/src/components/presentation/TusUploader.tsx new file mode 100644 index 0000000..3d8b983 --- /dev/null +++ b/apps/web/src/components/presentation/TusUploader.tsx @@ -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 = ({ + onSuccess, + onError, +}) => { + const { progress, isUploading, uploadError, handleFileUpload } = + useTusUpload(); + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file, onSuccess, onError); + }} + /> + + {isUploading && ( +
+ + {progress}% +
+ )} + + {uploadError && ( +
上传错误: {uploadError}
+ )} +
+ ); +}; + +export default TusUploader; diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d364783..1f22121 100755 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -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, diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts new file mode 100644 index 0000000..695d5a4 --- /dev/null +++ b/apps/web/src/hooks/useTusUpload.ts @@ -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 + >({}); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(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, + }; +} diff --git a/packages/client/src/api/hooks/useCourse.ts b/packages/client/src/api/hooks/useCourse.ts index 5061072..8e09848 100644 --- a/packages/client/src/api/hooks/useCourse.ts +++ b/packages/client/src/api/hooks/useCourse.ts @@ -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() - }, - }) - }; -} \ No newline at end of file + // Mutations + create: ReturnType; + // create: ReturnType; + update: ReturnType; + // update: ReturnType; + createMany: ReturnType; + deleteMany: ReturnType; + softDeleteByIds: ReturnType; + // softDeleteByIds: ReturnType; + updateOrder: ReturnType; + // updateOrder: ReturnType; +}; + +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(); + }, + }), + }; +} diff --git a/packages/client/src/api/hooks/useDepartment.ts b/packages/client/src/api/hooks/useDepartment.ts index 27e9c19..d12928b 100755 --- a/packages/client/src/api/hooks/useDepartment.ts +++ b/packages/client/src/api/hooks/useDepartment.ts @@ -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(); - - // 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 = (key: string) => { return findQueryData(queryClient, api.department, key); }; diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 3379233..f0a72d9 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -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" - ], -} \ No newline at end of file + "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"] +} diff --git a/packages/common/package.json b/packages/common/package.json index eb5c31c..9c6edea 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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" - } -} \ No newline at end of file + "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" + } +} diff --git a/packages/common/src/select.ts b/packages/common/src/select.ts index 7c5b46e..cd9979e 100644 --- a/packages/common/src/select.ts +++ b/packages/common/src/select.ts @@ -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, // 关联表选择 diff --git a/packages/common/tsup.config.ts b/packages/common/tsup.config.ts index 1eacf7a..8dc73a1 100644 --- a/packages/common/tsup.config.ts +++ b/packages/common/tsup.config.ts @@ -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/**", + ], }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 8f3fd0e..c02c2c3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "dev-static": "tsup --no-watch", "clean": "rimraf dist", "typecheck": "tsc --noEmit" }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index aea398b..77aa06e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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; -} \ No newline at end of file + // 如果提供了前缀,则添加前缀 + 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"; diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts new file mode 100644 index 0000000..d3ed883 --- /dev/null +++ b/packages/utils/src/types.ts @@ -0,0 +1 @@ +export type NonVoid = T extends void ? never : T; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94406e0..90950c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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