This commit is contained in:
longdayi 2025-02-05 09:37:22 +08:00
commit bcd67a61ac
40 changed files with 1497 additions and 1220 deletions

View File

@ -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 { AuthService } from './auth.service';
import { AuthSchema, JwtPayload } from '@nice/common'; import { AuthSchema, JwtPayload } from '@nice/common';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
@ -7,8 +22,8 @@ import { z } from 'zod';
import { FileValidationErrorType } from './types'; import { FileValidationErrorType } from './types';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
private logger = new Logger(AuthController.name) private logger = new Logger(AuthController.name);
constructor(private readonly authService: AuthService) { } constructor(private readonly authService: AuthService) {}
@Get('file') @Get('file')
async authFileRequset( async authFileRequset(
@Headers('x-original-uri') originalUri: string, @Headers('x-original-uri') originalUri: string,
@ -18,7 +33,6 @@ export class AuthController {
@Headers('host') host: string, @Headers('host') host: string,
@Headers('authorization') authorization: string, @Headers('authorization') authorization: string,
) { ) {
try { try {
const fileRequest = { const fileRequest = {
originalUri, originalUri,
@ -26,10 +40,11 @@ export class AuthController {
method, method,
queryParams, queryParams,
host, host,
authorization authorization,
}; };
const authResult = await this.authService.validateFileRequest(fileRequest); const authResult =
await this.authService.validateFileRequest(fileRequest);
if (!authResult.isValid) { if (!authResult.isValid) {
// 使用枚举类型进行错误处理 // 使用枚举类型进行错误处理
switch (authResult.error) { switch (authResult.error) {
@ -41,7 +56,9 @@ export class AuthController {
case FileValidationErrorType.INVALID_TOKEN: case FileValidationErrorType.INVALID_TOKEN:
throw new UnauthorizedException(authResult.error); throw new UnauthorizedException(authResult.error);
default: default:
throw new InternalServerErrorException(authResult.error || FileValidationErrorType.UNKNOWN_ERROR); throw new InternalServerErrorException(
authResult.error || FileValidationErrorType.UNKNOWN_ERROR,
);
} }
} }
return { return {
@ -51,17 +68,20 @@ export class AuthController {
}, },
}; };
} catch (error: any) { } 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; throw error;
} }
} }
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Get('user-profile') @Get('user-profile')
async getUserProfile(@Req() request: Request) { async getUserProfile(@Req() request: Request) {
const payload: JwtPayload = (request as any).user; const payload: JwtPayload = (request as any).user;
const { staff } = await UserProfileService.instance.getUserProfileById(payload.sub); const { staff } = await UserProfileService.instance.getUserProfileById(
return staff payload.sub,
);
return staff;
} }
@Post('login') @Post('login')
async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) { async login(@Body() body: z.infer<typeof AuthSchema.signInRequset>) {

View File

@ -4,14 +4,9 @@ import {
BadRequestException, BadRequestException,
Logger, Logger,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { StaffService } from '../models/staff/staff.service'; import { StaffService } from '../models/staff/staff.service';
import { import { db, AuthSchema, JwtPayload } from '@nice/common';
db,
AuthSchema,
JwtPayload,
} from '@nice/common';
import * as argon2 from 'argon2'; import * as argon2 from 'argon2';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { redis } from '@server/utils/redis/redis.service'; 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'; import { extractFileIdFromNginxUrl } from '@server/upload/utils';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private logger = new Logger(AuthService.name) private logger = new Logger(AuthService.name);
constructor( constructor(
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly sessionService: SessionService private readonly sessionService: SessionService,
) { ) {}
}
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> { async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
try { try {
// 基础参数验证 // 基础参数验证
@ -39,27 +32,32 @@ export class AuthService {
return { isValid: false, error: FileValidationErrorType.INVALID_URI }; return { isValid: false, error: FileValidationErrorType.INVALID_URI };
} }
const fileId = extractFileIdFromNginxUrl(params.originalUri); const fileId = extractFileIdFromNginxUrl(params.originalUri);
console.log(params.originalUri, fileId) console.log(params.originalUri, fileId);
const resource = await db.resource.findFirst({ where: { fileId } }); const resource = await db.resource.findFirst({ where: { fileId } });
// 资源验证 // 资源验证
if (!resource) { if (!resource) {
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND }; return {
isValid: false,
error: FileValidationErrorType.RESOURCE_NOT_FOUND,
};
} }
// 处理公开资源 // 处理公开资源
if (resource.isPublic) { if (resource.isPublic) {
return { return {
isValid: true, isValid: true,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} }
// 处理私有资源 // 处理私有资源
const token = extractTokenFromAuthorization(params.authorization); const token = extractTokenFromAuthorization(params.authorization);
if (!token) { 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) { if (!payload.sub) {
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN }; return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
} }
@ -67,9 +65,8 @@ export class AuthService {
return { return {
isValid: true, isValid: true,
userId: payload.sub, userId: payload.sub,
resourceType: resource.type || 'unknown' resourceType: resource.type || 'unknown',
}; };
} catch (error) { } catch (error) {
this.logger.error('File validation error:', error); this.logger.error('File validation error:', error);
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR }; return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
@ -93,7 +90,9 @@ export class AuthService {
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
async signIn(data: z.infer<typeof AuthSchema.signInRequset>): Promise<SessionInfo> { async signIn(
data: z.infer<typeof AuthSchema.signInRequset>,
): Promise<SessionInfo> {
const { username, password, phoneNumber } = data; const { username, password, phoneNumber } = data;
let staff = await db.staff.findFirst({ let staff = await db.staff.findFirst({
@ -113,7 +112,8 @@ export class AuthService {
if (!staff.enabled) { if (!staff.enabled) {
throw new UnauthorizedException('帐号已禁用'); throw new UnauthorizedException('帐号已禁用');
} }
const isPasswordMatch = phoneNumber || await argon2.verify(staff.password, password); const isPasswordMatch =
phoneNumber || (await argon2.verify(staff.password, password));
if (!isPasswordMatch) { if (!isPasswordMatch) {
throw new UnauthorizedException('帐号或密码错误'); throw new UnauthorizedException('帐号或密码错误');
} }
@ -143,7 +143,7 @@ export class AuthService {
const existingUser = await db.staff.findFirst({ const existingUser = await db.staff.findFirst({
where: { where: {
OR: [{ username }, { officerId }, { phoneNumber }], OR: [{ username }, { officerId }, { phoneNumber }],
deletedAt: null deletedAt: null,
}, },
}); });
@ -155,7 +155,7 @@ export class AuthService {
data: { data: {
...data, ...data,
domainId: data.deptId, domainId: data.deptId,
} },
}); });
} }
async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) { async refreshToken(data: z.infer<typeof AuthSchema.refreshTokenRequest>) {
@ -168,12 +168,17 @@ export class AuthService {
throw new UnauthorizedException('用户会话已过期'); 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) { if (!session || session.refresh_token !== refreshToken) {
throw new UnauthorizedException('用户会话已过期'); 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) { if (!user) {
throw new UnauthorizedException('用户不存在'); throw new UnauthorizedException('用户不存在');
} }
@ -186,14 +191,17 @@ export class AuthService {
const updatedSession = { const updatedSession = {
...session, ...session,
access_token: accessToken, 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( await this.sessionService.saveSession(
payload.sub, payload.sub,
updatedSession, updatedSession,
tokenConfig.accessToken.expirationTTL, tokenConfig.accessToken.expirationTTL,
); );
await redis.del(UserProfileService.instance.getProfileCacheKey(payload.sub)); await redis.del(
UserProfileService.instance.getProfileCacheKey(payload.sub),
);
return { return {
access_token: accessToken, access_token: accessToken,
access_token_expires_at: updatedSession.access_token_expires_at, access_token_expires_at: updatedSession.access_token_expires_at,
@ -212,7 +220,7 @@ export class AuthService {
where: { id: user?.id }, where: { id: user?.id },
data: { data: {
password: newPassword, password: newPassword,
} },
}); });
return { message: '密码已修改' }; return { message: '密码已修改' };
@ -232,5 +240,4 @@ export class AuthService {
return { message: '注销成功' }; return { message: '注销成功' };
} }
} }

View File

@ -8,7 +8,7 @@ async function bootstrap() {
// 启用 CORS 并允许所有来源 // 启用 CORS 并允许所有来源
app.enableCors({ app.enableCors({
origin: "*", origin: '*',
}); });
const wsService = app.get(WebSocketService); const wsService = app.get(WebSocketService);
await wsService.initialize(app.getHttpServer()); await wsService.initialize(app.getHttpServer());
@ -18,6 +18,5 @@ async function bootstrap() {
const port = process.env.SERVER_PORT || 3000; const port = process.env.SERVER_PORT || 3000;
await app.listen(port); await app.listen(port);
} }
bootstrap(); bootstrap();

View File

@ -1,238 +1,307 @@
import { Logger } from "@nestjs/common"; import { Logger } from '@nestjs/common';
import { UserProfile, db, RowModelRequest } from "@nice/common"; import { UserProfile, db, RowModelRequest } from '@nice/common';
import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder'; import { LogicalCondition, OperatorType, SQLBuilder } from './sql-builder';
export interface GetRowOptions { export interface GetRowOptions {
id?: string; id?: string;
ids?: string[]; ids?: string[];
extraCondition?: LogicalCondition; extraCondition?: LogicalCondition;
staff?: UserProfile; staff?: UserProfile;
} }
export abstract class RowModelService { export abstract class RowModelService {
private keywords: Set<string> = new Set([ private keywords: Set<string> = new Set([
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'JOIN', 'AND', 'OR' 'SELECT',
// 添加更多需要引号的关键词 'FROM',
]); 'WHERE',
protected logger = new Logger(this.tableName); 'ORDER',
protected constructor(protected tableName: string) { } 'BY',
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> { 'GROUP',
return row; 'JOIN',
'AND',
'OR',
// 添加更多需要引号的关键词
]);
protected logger = new Logger(this.tableName);
protected constructor(protected tableName: string) {}
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
return row;
}
protected async getRowsSqlWrapper(
sql: string,
request?: RowModelRequest,
staff?: UserProfile,
) {
if (request) return SQLBuilder.join([sql, this.getLimitSql(request)]);
return sql;
}
protected getLimitSql(request: RowModelRequest) {
return SQLBuilder.limit(
request.endRow - request.startRow,
request.startRow,
);
}
abstract createJoinSql(request?: RowModelRequest): string[];
async getRows(request: RowModelRequest, staff?: UserProfile) {
try {
let SQL = SQLBuilder.join([
SQLBuilder.select(this.getRowSelectCols(request)),
SQLBuilder.from(this.tableName),
SQLBuilder.join(this.createJoinSql(request)),
SQLBuilder.where(this.createGetRowsFilters(request, staff)),
SQLBuilder.groupBy(this.getGroupByColumns(request)),
SQLBuilder.orderBy(this.getOrderByColumns(request)),
]);
SQL = await this.getRowsSqlWrapper(SQL, request, staff);
this.logger.debug('getrows', SQL);
const results: any[] = (await db?.$queryRawUnsafe(SQL)) || [];
const rowDataDto = await Promise.all(
results.map((row) => this.getRowDto(row, staff)),
);
return {
rowCount: this.getRowCount(request, rowDataDto) || 0,
rowData: rowDataDto,
};
} catch (error: any) {
this.logger.error('Error executing getRows:', error);
} }
protected async getRowsSqlWrapper(sql: string, request?: RowModelRequest, staff?: UserProfile) { }
if (request) getRowCount(request: RowModelRequest, results: any[]) {
return SQLBuilder.join([sql, this.getLimitSql(request)]) if (results === null || results === undefined || results.length === 0) {
return sql return null;
} }
protected getLimitSql(request: RowModelRequest) { const currentLastRow = request.startRow + results.length;
return SQLBuilder.limit(request.endRow - request.startRow, request.startRow) return currentLastRow <= request.endRow ? currentLastRow : -1;
}
async getRowById(options: GetRowOptions): Promise<any> {
const {
id,
extraCondition = {
field: `${this.tableName}.deleted_at`,
op: 'blank',
type: 'date',
},
staff,
} = options;
return this.getSingleRow(
{ AND: [this.createGetByIdFilter(id!), extraCondition] },
staff,
);
}
async getRowByIds(options: GetRowOptions): Promise<any[]> {
const {
ids,
extraCondition = {
field: `${this.tableName}.deleted_at`,
op: 'blank',
type: 'date',
},
staff,
} = options;
return this.getMultipleRows(
{ AND: [this.createGetByIdsFilter(ids!), extraCondition] },
staff,
);
}
protected createGetRowsFilters(
request: RowModelRequest,
staff?: UserProfile,
): LogicalCondition {
let groupConditions: LogicalCondition[] = [];
if (this.isDoingTreeGroup(request)) {
groupConditions = [
{
field: 'parent_id',
op: 'equals' as OperatorType,
value: request.groupKeys[request.groupKeys.length - 1],
},
];
} else {
groupConditions = request?.groupKeys?.map((key, index) => ({
field: request.rowGroupCols[index].field,
op: 'equals' as OperatorType,
value: key,
}));
} }
abstract createJoinSql(request?: RowModelRequest): string[];
async getRows(request: RowModelRequest, staff?: UserProfile) {
try {
let SQL = SQLBuilder.join([ const condition: LogicalCondition = {
SQLBuilder.select(this.getRowSelectCols(request)), AND: [
SQLBuilder.from(this.tableName), ...groupConditions,
SQLBuilder.join(this.createJoinSql(request)), ...this.buildFilterConditions(request.filterModel),
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) 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))) const rowGroupCol = rowGroupCols[groupKeys!.length];
return { rowCount: this.getRowCount(request, rowDataDto) || 0, rowData: rowDataDto }; if (rowGroupCol) {
} catch (error: any) { colsToSelect.push(
this.logger.error('Error executing getRows:', error); `${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`,
);
}
colsToSelect.push(
...valueCols.map(
(valueCol) =>
`${wrapperSql ? '' : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
),
);
return colsToSelect;
}
getGroupByColumns(request: RowModelRequest): string[] {
return this.isDoingGroup(request)
? [request.rowGroupCols[request.groupKeys!.length]?.field]
: [];
}
getOrderByColumns(request: RowModelRequest): string[] {
const { sortModel, rowGroupCols, groupKeys } = request;
const grouping = this.isDoingGroup(request);
const sortParts: string[] = [];
if (sortModel) {
const groupColIds = rowGroupCols
.map((groupCol) => groupCol.id)
.slice(0, groupKeys.length + 1);
sortModel.forEach((item) => {
if (
!grouping ||
(groupColIds.indexOf(item.colId) >= 0 &&
rowGroupCols[groupKeys.length].field === item.colId)
) {
const colId = this.keywords.has(item.colId.toUpperCase())
? `"${item.colId}"`
: item.colId;
sortParts.push(`${colId} ${item.sort}`);
} }
} });
getRowCount(request: RowModelRequest, results: any[]) {
if (results === null || results === undefined || results.length === 0) {
return null;
}
const currentLastRow = request.startRow + results.length;
return currentLastRow <= request.endRow ? currentLastRow : -1;
} }
async getRowById(options: GetRowOptions): Promise<any> { return sortParts;
const { id, extraCondition = { }
field: `${this.tableName}.deleted_at`, isDoingGroup(requset: RowModelRequest): boolean {
op: "blank", return requset.rowGroupCols.length > requset.groupKeys.length;
type: "date" }
}, staff } = options; isDoingTreeGroup(requset: RowModelRequest): boolean {
return this.getSingleRow({ AND: [this.createGetByIdFilter(id!), extraCondition] }, staff); return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0;
} }
private async getSingleRow(
condition: LogicalCondition,
staff?: UserProfile,
): Promise<any> {
const results = await this.getRowsWithFilters(condition, staff);
return results[0];
}
private async getMultipleRows(
condition: LogicalCondition,
staff?: UserProfile,
): Promise<any[]> {
return this.getRowsWithFilters(condition, staff);
}
async getRowByIds(options: GetRowOptions): Promise<any[]> { private async getRowsWithFilters(
const { ids, extraCondition = { condition: LogicalCondition,
field: `${this.tableName}.deleted_at`, staff?: UserProfile,
op: "blank", ): Promise<any[]> {
type: "date" try {
}, staff } = options; const SQL = SQLBuilder.join([
return this.getMultipleRows({ AND: [this.createGetByIdsFilter(ids!), extraCondition] }, staff); SQLBuilder.select(this.createUnGroupingRowSelect()),
} SQLBuilder.from(this.tableName),
SQLBuilder.join(this.createJoinSql()),
SQLBuilder.where(condition),
]);
protected createGetRowsFilters(request: RowModelRequest, staff?: UserProfile): LogicalCondition { // this.logger.debug(SQL)
let groupConditions: LogicalCondition[] = [] const results: any[] = await db.$queryRawUnsafe(SQL);
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
}))
}
const condition: LogicalCondition = { const rowDataDto = await Promise.all(
AND: [...groupConditions, ...this.buildFilterConditions(request.filterModel)] results.map((item) => this.getRowDto(item, staff)),
} );
return condition; // rowDataDto = getUniqueItems(rowDataDto, "id")
} return rowDataDto;
private buildFilterConditions(filterModel: any): LogicalCondition[] { } catch (error) {
return filterModel this.logger.error('Error executing query:', error);
? Object.entries(filterModel)?.map(([key, item]) => SQLBuilder.createFilterSql(key === 'ag-Grid-AutoColumn' ? 'name' : key, item)) throw error;
: [];
} }
}
getRowSelectCols(request: RowModelRequest): string[] { async getAggValues(request: RowModelRequest) {
return this.isDoingGroup(request) try {
? this.createGroupingRowSelect(request) const SQL = SQLBuilder.join([
: this.createUnGroupingRowSelect(request); 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 buildAggGroupBy(): string[] {
} return [];
protected createAggSqlForWrapper(request: RowModelRequest) { }
const { rowGroupCols, valueCols, groupKeys } = request; protected buildAggSelect(valueCols: any[]): string[] {
return valueCols.map(valueCol => return valueCols.map(
`${valueCol.aggFunc}(${valueCol.field.replace('.', '_')}) AS ${valueCol.field.split('.').join('_')}` (valueCol) =>
); `${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`,
} );
protected createGroupingRowSelect(request: RowModelRequest, wrapperSql: boolean = false): string[] { }
const { rowGroupCols, valueCols, groupKeys } = request;
const colsToSelect: string[] = [];
const rowGroupCol = rowGroupCols[groupKeys!.length]; private createGetByIdFilter(id: string): LogicalCondition {
if (rowGroupCol) { return {
colsToSelect.push(`${rowGroupCol.field} AS ${rowGroupCol.field.replace('.', '_')}`); field: `${this.tableName}.id`,
} value: id,
colsToSelect.push(...valueCols.map(valueCol => op: 'equals',
`${wrapperSql ? "" : valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}` };
)); }
private createGetByIdsFilter(ids: string[]): LogicalCondition {
return colsToSelect; return {
} field: `${this.tableName}.id`,
value: ids,
getGroupByColumns(request: RowModelRequest): string[] { op: 'in',
return this.isDoingGroup(request) };
? [request.rowGroupCols[request.groupKeys!.length]?.field] }
: [];
}
getOrderByColumns(request: RowModelRequest): string[] {
const { sortModel, rowGroupCols, groupKeys } = request;
const grouping = this.isDoingGroup(request);
const sortParts: string[] = [];
if (sortModel) {
const groupColIds = rowGroupCols.map(groupCol => groupCol.id).slice(0, groupKeys.length + 1);
sortModel.forEach(item => {
if (!grouping || (groupColIds.indexOf(item.colId) >= 0 && rowGroupCols[groupKeys.length].field === item.colId)) {
const colId = this.keywords.has(item.colId.toUpperCase()) ? `"${item.colId}"` : item.colId;
sortParts.push(`${colId} ${item.sort}`);
}
});
}
return sortParts;
}
isDoingGroup(requset: RowModelRequest): boolean {
return requset.rowGroupCols.length > requset.groupKeys.length;
}
isDoingTreeGroup(requset: RowModelRequest): boolean {
return requset.rowGroupCols.length === 0 && requset.groupKeys.length > 0;
}
private async getSingleRow(condition: LogicalCondition, staff?: UserProfile): Promise<any> {
const results = await this.getRowsWithFilters(condition, staff)
return results[0]
}
private async getMultipleRows(condition: LogicalCondition, staff?: UserProfile): Promise<any[]> {
return this.getRowsWithFilters(condition, staff);
}
private async getRowsWithFilters(condition: LogicalCondition, staff?: UserProfile): Promise<any[]> {
try {
let SQL = SQLBuilder.join([
SQLBuilder.select(this.createUnGroupingRowSelect()),
SQLBuilder.from(this.tableName),
SQLBuilder.join(this.createJoinSql()),
SQLBuilder.where(condition)
]);
// this.logger.debug(SQL)
const results: any[] = await db.$queryRawUnsafe(SQL);
let rowDataDto = await Promise.all(results.map(item => this.getRowDto(item, staff)));
// rowDataDto = getUniqueItems(rowDataDto, "id")
return rowDataDto
} catch (error) {
this.logger.error('Error executing query:', error);
throw error;
}
}
async getAggValues(request: RowModelRequest) {
try {
const SQL = SQLBuilder.join([
SQLBuilder.select(this.buildAggSelect(request.valueCols)),
SQLBuilder.from(this.tableName),
SQLBuilder.join(this.createJoinSql(request)),
SQLBuilder.where(this.createGetRowsFilters(request)),
SQLBuilder.groupBy(this.buildAggGroupBy())
]);
const result: any[] = await db.$queryRawUnsafe(SQL);
return result[0];
} catch (error) {
this.logger.error('Error executing query:', error);
throw error;
}
}
protected buildAggGroupBy(): string[] {
return [];
}
protected buildAggSelect(valueCols: any[]): string[] {
return valueCols.map(valueCol =>
`${valueCol.aggFunc}(${valueCol.field}) AS ${valueCol.field.replace('.', '_')}`
);
}
private createGetByIdFilter(id: string): LogicalCondition {
return {
field: `${this.tableName}.id`,
value: id,
op: "equals"
}
}
private createGetByIdsFilter(ids: string[]): LogicalCondition {
return {
field: `${this.tableName}.id`,
value: ids,
op: "in"
};
}
} }

View File

@ -3,84 +3,92 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common'; import { Prisma, UpdateOrderSchema } from '@nice/common';
import { CourseService } from './course.service'; import { CourseService } from './course.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any() const CourseCreateArgsSchema: ZodType<Prisma.CourseCreateArgs> = z.any();
const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any() const CourseUpdateArgsSchema: ZodType<Prisma.CourseUpdateArgs> = z.any();
const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> = z.any() const CourseCreateManyInputSchema: ZodType<Prisma.CourseCreateManyInput> =
const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> = z.any() z.any();
const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any() const CourseDeleteManyArgsSchema: ZodType<Prisma.CourseDeleteManyArgs> =
const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any() z.any();
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any() const CourseFindManyArgsSchema: ZodType<Prisma.CourseFindManyArgs> = z.any();
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any() const CourseFindFirstArgsSchema: ZodType<Prisma.CourseFindFirstArgs> = z.any();
const CourseWhereInputSchema: ZodType<Prisma.CourseWhereInput> = z.any();
const CourseSelectSchema: ZodType<Prisma.CourseSelect> = z.any();
@Injectable() @Injectable()
export class CourseRouter { export class CourseRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly courseService: CourseService, private readonly courseService: CourseService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(CourseCreateArgsSchema) .input(CourseCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; 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;
return await this.courseService.createMany({ data: input }, staff); return await this.courseService.create(input, { staff });
}), }),
deleteMany: this.trpc.procedure update: this.trpc.protectProcedure
.input(CourseDeleteManyArgsSchema) .input(CourseUpdateArgsSchema)
.mutation(async ({ input }) => { .mutation(async ({ ctx, input }) => {
return await this.courseService.deleteMany(input); const { staff } = ctx;
}), return await this.courseService.update(input, { staff });
findFirst: this.trpc.procedure }),
.input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword createMany: this.trpc.protectProcedure
.query(async ({ input }) => { .input(z.array(CourseCreateManyInputSchema))
return await this.courseService.findFirst(input); .mutation(async ({ ctx, input }) => {
}), const { staff } = ctx;
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema return await this.courseService.createMany({ data: input }, staff);
.mutation(async ({ input }) => { }),
return this.courseService.softDeleteByIds(input.ids); deleteMany: this.trpc.procedure
}), .input(CourseDeleteManyArgsSchema)
updateOrder: this.trpc.protectProcedure .mutation(async ({ input }) => {
.input(UpdateOrderSchema) return await this.courseService.deleteMany(input);
.mutation(async ({ input }) => { }),
return this.courseService.updateOrder(input); findFirst: this.trpc.procedure
}), .input(CourseFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
findMany: this.trpc.procedure .query(async ({ input }) => {
.input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword return await this.courseService.findFirst(input);
.query(async ({ input }) => { }),
return await this.courseService.findMany(input); softDeleteByIds: this.trpc.protectProcedure
}), .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
findManyWithCursor: this.trpc.protectProcedure .mutation(async ({ input }) => {
.input(z.object({ return this.courseService.softDeleteByIds(input.ids);
cursor: z.any().nullish(), }),
take: z.number().optional(), updateOrder: this.trpc.protectProcedure
where: CourseWhereInputSchema.optional(), .input(UpdateOrderSchema)
select: CourseSelectSchema.optional() .mutation(async ({ input }) => {
})) return this.courseService.updateOrder(input);
.query(async ({ ctx, input }) => { }),
return await this.courseService.findManyWithCursor(input); findMany: this.trpc.procedure
}), .input(CourseFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
findManyWithPagination: this.trpc.procedure .query(async ({ input }) => {
.input(z.object({ return await this.courseService.findMany(input);
page: z.number().optional(), }),
pageSize: z.number().optional(), findManyWithCursor: this.trpc.protectProcedure
where: CourseWhereInputSchema.optional(), .input(
select: CourseSelectSchema.optional() z.object({
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword cursor: z.any().nullish(),
.query(async ({ input }) => { take: z.number().optional(),
return await this.courseService.findManyWithPagination(input); 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);
}),
});
} }

View File

@ -1,46 +1,49 @@
import { db, EnrollmentStatus, PostType } from "@nice/common"; import { db, EnrollmentStatus, PostType } from '@nice/common';
// 更新课程评价统计 // 更新课程评价统计
export async function updateCourseReviewStats(courseId: string) { export async function updateCourseReviewStats(courseId: string) {
const reviews = await db.post.findMany({ const reviews = await db.post.findMany({
where: { where: {
courseId, courseId,
type: PostType.COURSE_REVIEW, type: PostType.COURSE_REVIEW,
deletedAt: null deletedAt: null,
}, },
select: { rating: true } select: { rating: true },
}); });
const numberOfReviews = reviews.length; const numberOfReviews = reviews.length;
const averageRating = numberOfReviews > 0 const averageRating =
? reviews.reduce((sum, review) => sum + review.rating, 0) / numberOfReviews numberOfReviews > 0
: 0; ? reviews.reduce((sum, review) => sum + review.rating, 0) /
numberOfReviews
: 0;
return db.course.update({ return db.course.update({
where: { id: courseId }, where: { id: courseId },
data: { numberOfReviews, averageRating } data: {
}); // numberOfReviews,
//averageRating,
},
});
} }
// 更新课程注册统计 // 更新课程注册统计
export async function updateCourseEnrollmentStats(courseId: string) { export async function updateCourseEnrollmentStats(courseId: string) {
const completedEnrollments = await db.enrollment.count({ const completedEnrollments = await db.enrollment.count({
where: { where: {
courseId, courseId,
status: EnrollmentStatus.COMPLETED status: EnrollmentStatus.COMPLETED,
} },
}); });
const totalEnrollments = await db.enrollment.count({ const totalEnrollments = await db.enrollment.count({
where: { courseId } where: { courseId },
}); });
const completionRate = totalEnrollments > 0 const completionRate =
? (completedEnrollments / totalEnrollments) * 100 totalEnrollments > 0 ? (completedEnrollments / totalEnrollments) * 100 : 0;
: 0; return db.course.update({
return db.course.update({ where: { id: courseId },
where: { id: courseId }, data: {
data: { // numberOfStudents: totalEnrollments,
numberOfStudents: totalEnrollments, // completionRate,
completionRate },
} });
});
} }

View File

@ -1,39 +1,39 @@
import { db, Lecture } from "@nice/common" import { db, Lecture } from '@nice/common';
export async function updateSectionLectureStats(sectionId: string) { export async function updateSectionLectureStats(sectionId: string) {
const sectionStats = await db.lecture.aggregate({ const sectionStats = await db.lecture.aggregate({
where: { where: {
sectionId, sectionId,
deletedAt: null deletedAt: null,
}, },
_count: { _all: true }, _count: { _all: true },
_sum: { duration: true } _sum: { duration: true },
}); });
await db.section.update({ await db.section.update({
where: { id: sectionId }, where: { id: sectionId },
data: { data: {
totalLectures: sectionStats._count._all, // totalLectures: sectionStats._count._all,
totalDuration: sectionStats._sum.duration || 0 // totalDuration: sectionStats._sum.duration || 0,
} },
}); });
} }
export async function updateCourseLectureStats(courseId: string) { export async function updateCourseLectureStats(courseId: string) {
const courseStats = await db.lecture.aggregate({ const courseStats = await db.lecture.aggregate({
where: { where: {
courseId, courseId,
deletedAt: null deletedAt: null,
}, },
_count: { _all: true }, _count: { _all: true },
_sum: { duration: true } _sum: { duration: true },
}); });
await db.course.update({ await db.course.update({
where: { id: courseId }, where: { id: courseId },
data: { data: {
totalLectures: courseStats._count._all, //totalLectures: courseStats._count._all,
totalDuration: courseStats._sum.duration || 0 //totalDuration: courseStats._sum.duration || 0,
} },
}); });
} }

View File

@ -17,20 +17,22 @@ export class TermRowService extends RowCacheService {
createUnGroupingRowSelect( createUnGroupingRowSelect(
requset: z.infer<typeof TermMethodSchema.getRows>, requset: z.infer<typeof TermMethodSchema.getRows>,
): string[] { ): string[] {
const result = super.createUnGroupingRowSelect(requset).concat([ const result = super
`${this.tableName}.name AS name`, .createUnGroupingRowSelect(requset)
`${this.tableName}.order AS order`, .concat([
`${this.tableName}.has_children AS has_children`, `${this.tableName}.name AS name`,
`${this.tableName}.parent_id AS parent_id`, `${this.tableName}.order AS order`,
`${this.tableName}.domain_id AS domain_id`, `${this.tableName}.has_children AS has_children`,
`taxonomy.name AS taxonomy_name`, `${this.tableName}.parent_id AS parent_id`,
`taxonomy.id AS taxonomy_id` `${this.tableName}.domain_id AS domain_id`,
]); `taxonomy.name AS taxonomy_name`,
`taxonomy.id AS taxonomy_id`,
]);
return result; return result;
} }
createJoinSql(request?: RowModelRequest): string[] { createJoinSql(request?: RowModelRequest): string[] {
return [ return [
`LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id` `LEFT JOIN taxonomy ON ${this.tableName}.taxonomy_id = taxonomy.id`,
]; ];
} }
protected createGetRowsFilters( protected createGetRowsFilters(
@ -53,7 +55,7 @@ export class TermRowService extends RowCacheService {
} else if (parentId === null) { } else if (parentId === null) {
condition.AND.push({ condition.AND.push({
field: `${this.tableName}.parent_id`, field: `${this.tableName}.parent_id`,
op: "blank", op: 'blank',
}); });
} }
} }
@ -66,7 +68,7 @@ export class TermRowService extends RowCacheService {
} else if (domainId === null) { } else if (domainId === null) {
condition.AND.push({ condition.AND.push({
field: `${this.tableName}.domain_id`, field: `${this.tableName}.domain_id`,
op: "blank", op: 'blank',
}); });
} }
if (taxonomyId) { if (taxonomyId) {
@ -84,8 +86,6 @@ export class TermRowService extends RowCacheService {
}); });
} }
return condition; return condition;
} }
} }

View File

@ -1,28 +1,24 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class PostProcessService { export class PostProcessService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {}
@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()}`;
}
}
} }

View File

@ -25,11 +25,10 @@ import { join } from 'path';
{ {
name: 'file-queue', // 新增文件处理队列 name: 'file-queue', // 新增文件处理队列
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径 processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
} },
), ),
], ],
providers: [Logger], providers: [Logger],
exports: [] exports: [],
}) })
export class QueueModule { } export class QueueModule {}

View File

@ -1,70 +1,68 @@
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from '@nestjs/bullmq';
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import { Queue } from "bullmq"; import { Queue } from 'bullmq';
import { ObjectType } from "@nice/common"; import { ObjectType } from '@nice/common';
import { QueueJobType } from "../types"; import { QueueJobType } from '../types';
@Injectable() @Injectable()
export class StatsService { export class StatsService {
constructor( constructor(@InjectQueue('general') private generalQueue: Queue) {
@InjectQueue('general') private generalQueue: Queue EventBus.on('dataChanged', async ({ type, data }) => {
) { const jobOptions = {
EventBus.on("dataChanged", async ({ type, data }) => { removeOnComplete: true,
const jobOptions = { jobId: this.generateJobId(type as ObjectType, data), // 使用唯一ID防止重复任务
removeOnComplete: true, };
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务 switch (type) {
}; case ObjectType.ENROLLMENT:
switch (type) { await this.generalQueue.add(
case ObjectType.ENROLLMENT: QueueJobType.UPDATE_STATS,
await this.generalQueue.add( {
QueueJobType.UPDATE_STATS, courseId: data.courseId,
{ type: ObjectType.ENROLLMENT,
courseId: data.courseId, },
type: ObjectType.ENROLLMENT jobOptions,
}, );
jobOptions break;
);
break;
case ObjectType.LECTURE: case ObjectType.LECTURE:
await this.generalQueue.add( await this.generalQueue.add(
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
sectionId: data.sectionId, sectionId: data.sectionId,
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.LECTURE type: ObjectType.LECTURE,
}, },
jobOptions jobOptions,
); );
break; break;
case ObjectType.POST: case ObjectType.POST:
if (data.courseId) { if (data.courseId) {
await this.generalQueue.add( await this.generalQueue.add(
QueueJobType.UPDATE_STATS, QueueJobType.UPDATE_STATS,
{ {
courseId: data.courseId, courseId: data.courseId,
type: ObjectType.POST type: ObjectType.POST,
}, },
jobOptions jobOptions,
); );
} }
break; break;
} }
}); });
} }
private generateJobId(type: ObjectType, data: any): string { private generateJobId(type: ObjectType, data: any): string {
// 根据类型和相关ID生成唯一的job标识 // 根据类型和相关ID生成唯一的job标识
switch (type) { switch (type) {
case ObjectType.ENROLLMENT: case ObjectType.ENROLLMENT:
return `stats_${type}_${data.courseId}`; return `stats_${type}_${data.courseId}`;
case ObjectType.LECTURE: case ObjectType.LECTURE:
return `stats_${type}_${data.courseId}_${data.sectionId}`; return `stats_${type}_${data.courseId}_${data.sectionId}`;
case ObjectType.POST: case ObjectType.POST:
return `stats_${type}_${data.courseId}`; return `stats_${type}_${data.courseId}`;
default: default:
return `stats_${type}_${Date.now()}`; return `stats_${type}_${Date.now()}`;
}
} }
}
} }

View File

@ -1,4 +1,4 @@
export enum QueueJobType { export enum QueueJobType {
UPDATE_STATS = "update_stats", UPDATE_STATS = 'update_stats',
FILE_PROCESS = "file_process" FILE_PROCESS = 'file_process',
} }

View File

@ -6,17 +6,17 @@ import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor
import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor'; import { VideoProcessor } from '@server/models/resource/processor/VideoProcessor';
const logger = new Logger('FileProcessorWorker'); const logger = new Logger('FileProcessorWorker');
const pipeline = new ResourceProcessingPipeline() const pipeline = new ResourceProcessingPipeline()
.addProcessor(new ImageProcessor()) .addProcessor(new ImageProcessor())
.addProcessor(new VideoProcessor()) .addProcessor(new VideoProcessor());
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.FILE_PROCESS) { if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job) console.log('job', job);
const { resource } = job.data; const { resource } = job.data;
if (!resource) { if (!resource) {
throw new Error('No resource provided in job data'); throw new Error('No resource provided in job data');
}
const result = await pipeline.execute(resource);
return result;
} }
const result = await pipeline.execute(resource);
return result;
}
} }

View File

@ -1,49 +1,52 @@
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { import {
updateCourseLectureStats, updateCourseLectureStats,
updateSectionLectureStats updateSectionLectureStats,
} from '@server/models/lecture/utils'; } from '@server/models/lecture/utils';
import { ObjectType } from '@nice/common'; import { ObjectType } from '@nice/common';
import { import {
updateCourseEnrollmentStats, updateCourseEnrollmentStats,
updateCourseReviewStats updateCourseReviewStats,
} from '@server/models/course/utils'; } from '@server/models/course/utils';
import { QueueJobType } from '../types'; import { QueueJobType } from '../types';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
try { try {
if (job.name === QueueJobType.UPDATE_STATS) { if (job.name === QueueJobType.UPDATE_STATS) {
const { sectionId, courseId, type } = job.data; const { sectionId, courseId, type } = job.data;
// 处理 section 统计 // 处理 section 统计
if (sectionId) { if (sectionId) {
await updateSectionLectureStats(sectionId); await updateSectionLectureStats(sectionId);
logger.debug(`Updated section stats for sectionId: ${sectionId}`); logger.debug(`Updated section stats for sectionId: ${sectionId}`);
} }
// 如果没有 courseId提前返回 // 如果没有 courseId提前返回
if (!courseId) { if (!courseId) {
return; return;
} }
// 处理 course 相关统计 // 处理 course 相关统计
switch (type) { switch (type) {
case ObjectType.LECTURE: case ObjectType.LECTURE:
await updateCourseLectureStats(courseId); await updateCourseLectureStats(courseId);
break; break;
case ObjectType.ENROLLMENT: case ObjectType.ENROLLMENT:
await updateCourseEnrollmentStats(courseId); await updateCourseEnrollmentStats(courseId);
break; break;
case ObjectType.POST: case ObjectType.POST:
await updateCourseReviewStats(courseId); await updateCourseReviewStats(courseId);
break; break;
default: default:
logger.warn(`Unknown update stats type: ${type}`); logger.warn(`Unknown update stats type: ${type}`);
} }
logger.debug(`Updated course stats for courseId: ${courseId}, 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);
} }
} catch (error: any) {
logger.error(
`Error processing stats update job: ${error.message}`,
error.stack,
);
}
} }

View File

@ -19,7 +19,7 @@ export class InitService {
private readonly minioService: MinioService, private readonly minioService: MinioService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly genDevService: GenDevService, private readonly genDevService: GenDevService,
) { } ) {}
private async createRoles() { private async createRoles() {
this.logger.log('Checking existing system roles'); this.logger.log('Checking existing system roles');
for (const role of InitRoles) { for (const role of InitRoles) {

View File

@ -1,7 +1,7 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; 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 { FileStore } from '@nice/tus';
import { Request, Response } from "express" import { Request, Response } from 'express';
import { db, ResourceStatus } from '@nice/common'; import { db, ResourceStatus } from '@nice/common';
import { getFilenameWithoutExt } from '@server/utils/file'; import { getFilenameWithoutExt } from '@server/utils/file';
import { ResourceService } from '@server/models/resource/resource.service'; import { ResourceService } from '@server/models/resource/resource.service';
@ -12,104 +12,122 @@ import { QueueJobType } from '@server/queue/types';
import { nanoid } from 'nanoid-cjs'; import { nanoid } from 'nanoid-cjs';
import { slugify } from 'transliteration'; import { slugify } from 'transliteration';
const FILE_UPLOAD_CONFIG = { const FILE_UPLOAD_CONFIG = {
directory: process.env.UPLOAD_DIR, directory: process.env.UPLOAD_DIR,
maxSizeBytes: 20_000_000_000, // 20GB maxSizeBytes: 20_000_000_000, // 20GB
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours
}; };
@Injectable() @Injectable()
export class TusService implements OnModuleInit { export class TusService implements OnModuleInit {
private readonly logger = new Logger(TusService.name); private readonly logger = new Logger(TusService.name);
private tusServer: Server; private tusServer: Server;
constructor(private readonly resourceService: ResourceService, constructor(
@InjectQueue("file-queue") private fileQueue: Queue private readonly resourceService: ResourceService,
) { } @InjectQueue('file-queue') private fileQueue: Queue,
onModuleInit() { ) {}
this.initializeTusServer(); onModuleInit() {
this.setupTusEventHandlers(); this.initializeTusServer();
} this.setupTusEventHandlers();
private initializeTusServer() { }
this.tusServer = new Server({ private initializeTusServer() {
namingFunction(req, metadata) { this.tusServer = new Server({
const safeFilename = slugify(metadata.filename); namingFunction(req, metadata) {
const now = new Date(); const safeFilename = slugify(metadata.filename);
const year = now.getFullYear(); const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, '0'); const year = now.getFullYear();
const day = String(now.getDate()).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
const uniqueId = nanoid(10); const day = String(now.getDate()).padStart(2, '0');
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; const uniqueId = nanoid(10);
}, return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
path: '/upload', },
datastore: new FileStore({ path: '/upload',
directory: FILE_UPLOAD_CONFIG.directory, datastore: new FileStore({
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod directory: FILE_UPLOAD_CONFIG.directory,
}), expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod,
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, }),
postReceiveInterval: 1000, maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
getFileIdFromRequest: (req, lastPath) => { postReceiveInterval: 1000,
const match = req.url.match(/\/upload\/(.+)/); getFileIdFromRequest: (req, lastPath) => {
return match ? match[1] : lastPath; const match = req.url.match(/\/upload\/(.+)/);
} return match ? match[1] : lastPath;
}); },
} });
}
private setupTusEventHandlers() { private setupTusEventHandlers() {
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this)); this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this));
this.tusServer.on("POST_FINISH", this.handleUploadFinish.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) private async handleUploadFinish(
const filename = upload.metadata.filename req: Request,
await this.resourceService.create({ res: Response,
data: { upload: Upload,
title: getFilenameWithoutExt(upload.metadata.filename), ) {
fileId, // 移除最后的文件名 try {
url: upload.id, console.log('upload.id', upload.id);
metadata: upload.metadata, console.log('fileId', this.getFileId(upload.id));
status: ResourceStatus.UPLOADING const resource = await this.resourceService.update({
} where: { fileId: this.getFileId(upload.id) },
}); data: { status: ResourceStatus.UPLOADED },
} catch (error) { });
this.logger.error('Failed to create resource during upload', error); 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) { @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
try { async cleanupExpiredUploads() {
const resource = await this.resourceService.update({ try {
where: { fileId: this.getFileId(upload.id) }, // Delete incomplete uploads older than 24 hours
data: { status: ResourceStatus.UPLOADED } const deletedResources = await db.resource.deleteMany({
}); where: {
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id }) createdAt: {
this.logger.log(`Upload finished ${resource.url}`); lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod),
} catch (error) { },
this.logger.error('Failed to update resource after upload', error); 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 handleTus(req: Request, res: Response) {
async cleanupExpiredUploads() { return this.tusServer.handle(req, res);
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);
}
} }

View File

@ -1,19 +1,24 @@
export interface UploadCompleteEvent { export interface UploadCompleteEvent {
identifier: string; identifier: string;
filename: string; filename: string;
size: number; size: number;
hash: string; hash: string;
integrityVerified: boolean; integrityVerified: boolean;
} }
export type UploadEvent = { export type UploadEvent = {
uploadStart: { identifier: string; filename: string; totalSize: number, resuming?: boolean }; uploadStart: {
uploadComplete: UploadCompleteEvent identifier: string;
uploadError: { identifier: string; error: string, filename: string }; filename: string;
} totalSize: number;
resuming?: boolean;
};
uploadComplete: UploadCompleteEvent;
uploadError: { identifier: string; error: string; filename: string };
};
export interface UploadLock { export interface UploadLock {
clientId: string; clientId: string;
timestamp: number; timestamp: number;
} }
// 添加重试机制,处理临时网络问题 // 添加重试机制,处理临时网络问题
// 实现定期清理过期的临时文件 // 实现定期清理过期的临时文件

View File

@ -1,55 +1,54 @@
import { import {
Controller, Controller,
All, All,
Req, Req,
Res, Res,
Get, Get,
Post, Post,
Patch, Patch,
Param, Param,
Delete, Delete,
Head, Head,
Options, Options,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request, Response } from "express" import { Request, Response } from 'express';
import { TusService } from './tus.service'; import { TusService } from './tus.service';
@Controller('upload') @Controller('upload')
export class UploadController { export class UploadController {
constructor(private readonly tusService: TusService) { } constructor(private readonly tusService: TusService) {}
// @Post() // @Post()
// async handlePost(@Req() req: Request, @Res() res: Response) { // async handlePost(@Req() req: Request, @Res() res: Response) {
// return this.tusService.handleTus(req, res); // return this.tusService.handleTus(req, res);
// } // }
@Options()
async handleOptions(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Options() @Head()
async handleOptions(@Req() req: Request, @Res() res: Response) { async handleHead(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Head() @Post()
async handleHead(@Req() req: Request, @Res() res: Response) { async handlePost(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Get('/*')
async handleGet(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Post() @Patch('/*')
async handlePost(@Req() req: Request, @Res() res: Response) { async handlePatch(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
@Get("/*")
async handleGet(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
@Patch("/*") // Keeping the catch-all method as a fallback
async handlePatch(@Req() req: Request, @Res() res: Response) { @All()
return this.tusService.handleTus(req, res); async handleUpload(@Req() req: Request, @Res() res: Response) {
} return this.tusService.handleTus(req, res);
}
// Keeping the catch-all method as a fallback
@All()
async handleUpload(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res);
}
} }

View File

@ -5,13 +5,13 @@ import { TusService } from './tus.service';
import { ResourceModule } from '@server/models/resource/resource.module'; import { ResourceModule } from '@server/models/resource/resource.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({ BullModule.registerQueue({
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致 name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
}), }),
ResourceModule ResourceModule,
], ],
controllers: [UploadController], controllers: [UploadController],
providers: [TusService], providers: [TusService],
}) })
export class UploadModule { } export class UploadModule {}

View File

@ -1,11 +1,10 @@
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import path from 'path'; import path from 'path';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
dotenv.config(); dotenv.config();
export function getFilenameWithoutExt(filename: string) { export function getFilenameWithoutExt(filename: string) {
return filename ? filename.replace(/\.[^/.]+$/, '') : filename; return filename ? filename.replace(/\.[^/.]+$/, '') : filename;
} }
/** /**
* SHA-256 * SHA-256
@ -13,31 +12,31 @@ export function getFilenameWithoutExt(filename: string) {
* @returns Promise<string> * @returns Promise<string>
*/ */
export async function calculateFileHash(filePath: string): Promise<string> { export async function calculateFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 创建一个 SHA-256 哈希对象 // 创建一个 SHA-256 哈希对象
const hash = createHash('sha256'); const hash = createHash('sha256');
// 创建文件读取流 // 创建文件读取流
const readStream = createReadStream(filePath); const readStream = createReadStream(filePath);
// 处理读取错误 // 处理读取错误
readStream.on('error', (error) => { readStream.on('error', (error) => {
reject(new Error(`Failed to read file: ${error.message}`)); 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}`));
});
}); });
// 处理哈希计算错误
hash.on('error', (error) => {
reject(new Error(`Failed to calculate hash: ${error.message}`));
});
// 流式处理文件内容
readStream
.pipe(hash)
.on('finish', () => {
// 获取最终的哈希值(十六进制格式)
const fileHash = hash.digest('hex');
resolve(fileHash);
})
.on('error', (error) => {
reject(new Error(`Hash calculation failed: ${error.message}`));
});
});
} }
/** /**
@ -46,9 +45,9 @@ export async function calculateFileHash(filePath: string): Promise<string> {
* @returns string Buffer * @returns string Buffer
*/ */
export function calculateBufferHash(buffer: Buffer): string { export function calculateBufferHash(buffer: Buffer): string {
const hash = createHash('sha256'); const hash = createHash('sha256');
hash.update(buffer); hash.update(buffer);
return hash.digest('hex'); return hash.digest('hex');
} }
/** /**
@ -57,11 +56,11 @@ export function calculateBufferHash(buffer: Buffer): string {
* @returns string * @returns string
*/ */
export function calculateStringHash(content: string): string { export function calculateStringHash(content: string): string {
const hash = createHash('sha256'); const hash = createHash('sha256');
hash.update(content); hash.update(content);
return hash.digest('hex'); return hash.digest('hex');
} }
export const getUploadFilePath = (fileId: string): string => { export const getUploadFilePath = (fileId: string): string => {
const uploadDirectory = process.env.UPLOAD_DIR; const uploadDirectory = process.env.UPLOAD_DIR;
return path.join(uploadDirectory, fileId); return path.join(uploadDirectory, fileId);
}; };

View File

@ -3,24 +3,24 @@ import * as Minio from 'minio';
@Injectable() @Injectable()
export class MinioService { export class MinioService {
private readonly logger = new Logger(MinioService.name) private readonly logger = new Logger(MinioService.name);
private readonly minioClient: Minio.Client; private readonly minioClient: Minio.Client;
constructor() { constructor() {
this.minioClient = new Minio.Client({ this.minioClient = new Minio.Client({
endPoint: process.env.MINIO_HOST || 'localhost', endPoint: process.env.MINIO_HOST || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'), port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: false, useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin' secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
}); });
} }
async createBucket(bucketName: string): Promise<void> { async createBucket(bucketName: string): Promise<void> {
const exists = await this.minioClient.bucketExists(bucketName); const exists = await this.minioClient.bucketExists(bucketName);
if (!exists) { if (!exists) {
await this.minioClient.makeBucket(bucketName, ''); await this.minioClient.makeBucket(bucketName, '');
this.logger.log(`Bucket ${bucketName} created successfully.`); this.logger.log(`Bucket ${bucketName} created successfully.`);
} else { } else {
this.logger.log(`Bucket ${bucketName} already exists.`); this.logger.log(`Bucket ${bucketName} already exists.`);
}
} }
}
} }

View File

@ -1,5 +1,4 @@
import CourseDetail from "@web/src/components/models/course/detail/CourseDetail"; 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"; import { useParams } from "react-router-dom";
export function CourseDetailPage() { export function CourseDetailPage() {

View File

@ -52,7 +52,7 @@ export default function InstructorCoursesPage() {
renderItem={(course) => ( renderItem={(course) => (
<CourseCard <CourseCard
onClick={() => { onClick={() => {
navigate(`/course/${course.id}/manage`, { navigate(`/course/${course.id}/editor`, {
replace: true, replace: true,
}); });
}} }}

View File

@ -1,84 +1,25 @@
import GraphEditor from '@web/src/components/common/editor/graph/GraphEditor'; import GraphEditor from "@web/src/components/common/editor/graph/GraphEditor";
import MindMapEditor from '@web/src/components/presentation/mind-map'; import FileUploader from "@web/src/components/common/uploader/FileUploader";
import React, { useState, useCallback } from 'react';
import * as tus from 'tus-js-client'; import React, { useState, useCallback } from "react";
import * as tus from "tus-js-client";
interface TusUploadProps { interface TusUploadProps {
onSuccess?: (response: any) => void; onSuccess?: (response: any) => void;
onError?: (error: Error) => void; onError?: (error: Error) => void;
} }
const TusUploader: React.FC<TusUploadProps> = ({ const HomePage: React.FC<TusUploadProps> = ({ onSuccess, onError }) => {
onSuccess, return (
onError <div>
}) => { <FileUploader></FileUploader>
const [progress, setProgress] = useState<number>(0); <div className="w-full" style={{ height: 800 }}>
const [isUploading, setIsUploading] = useState<boolean>(false); <GraphEditor></GraphEditor>
const [uploadError, setUploadError] = useState<string | null>(null); </div>
const handleFileUpload = useCallback((file: File) => { {/* <div className=' h-screen'>
if (!file) return;
setIsUploading(true);
setProgress(0);
setUploadError(null);
// Extract file extension
const extension = file.name.split('.').pop() || '';
const upload = new tus.Upload(file, {
endpoint: "http://localhost:3000/upload",
retryDelays: [0, 1000, 3000, 5000],
metadata: {
filename: file.name,
size: file.size.toString(),
mimeType: file.type,
extension: extension,
modifiedAt: new Date(file.lastModified).toISOString(),
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
setProgress(Number(percentage));
},
onSuccess: () => {
setIsUploading(false);
setProgress(100);
onSuccess && onSuccess(upload);
},
onError: (error) => {
setIsUploading(false);
setUploadError(error.message);
onError && onError(error);
}
});
upload.start();
}, [onSuccess, onError]);
return (
<div>
<div className='w-full' style={{ height: 800 }}>
<GraphEditor></GraphEditor>
</div>
{/* <div className=' h-screen'>
<MindMap></MindMap> <MindMap></MindMap>
</div> */} </div> */}
{/* <MindMapEditor></MindMapEditor> */} {/* <MindMapEditor></MindMapEditor> */}
</div>
<input );
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file);
}}
/>
{isUploading && (
<div>
<progress value={progress} max="100" />
<span>{progress}%</span>
</div>
)}
{uploadError && (
<div style={{ color: 'red' }}>
: {uploadError}
</div>
)}
</div>
);
}; };
export default TusUploader; export default HomePage;

View File

@ -1,211 +1,237 @@
import { useState, useCallback, useRef, memo } from 'react' // FileUploader.tsx
import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline' import React, { useRef, memo, useState } from "react";
import * as tus from 'tus-js-client' import {
import { motion, AnimatePresence } from 'framer-motion' CloudArrowUpIcon,
import { toast } from 'react-hot-toast' 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 { interface FileUploaderProps {
endpoint?: string endpoint?: string;
onSuccess?: (url: string) => void onSuccess?: (url: string) => void;
onError?: (error: Error) => void onError?: (error: Error) => void;
maxSize?: number maxSize?: number;
allowedTypes?: string[] allowedTypes?: string[];
placeholder?: string placeholder?: string;
} }
const FileItem = memo(({ file, progress, onRemove }: { interface FileItemProps {
file: File file: File;
progress?: number progress?: number;
onRemove: (name: string) => void onRemove: (name: string) => void;
}) => ( isUploaded: boolean;
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200"
>
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{file.name}</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}
>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">{progress}%</span>
</div>
)}
</div>
</motion.div>
))
export default function FileUploader({
endpoint='',
onSuccess,
onError,
maxSize = 100,
placeholder = '点击或拖拽文件到这里上传',
allowedTypes = ['*/*']
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<File[]>([])
const [progress, setProgress] = useState<{ [key: string]: number }>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const handleError = useCallback((error: Error) => {
toast.error(error.message)
onError?.(error)
}, [onError])
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 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 uploadFile = async (file: File) => {
try {
validateFile(file)
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
})
}
})
upload.start()
} catch (error) {
handleError(error as Error)
}
}
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
setFiles(prev => [...prev, ...droppedFiles])
droppedFiles.forEach(uploadFile)
}, [])
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
setFiles(prev => [...prev, ...selectedFiles])
selectedFiles.forEach(uploadFile)
}
}
const removeFile = (fileName: string) => {
setFiles(prev => prev.filter(file => file.name !== fileName))
setProgress(prev => {
const newProgress = { ...prev }
delete newProgress[fileName]
return newProgress
})
}
return (
<div className="w-full space-y-4">
<motion.div
className={`relative border-2 border-dashed rounded-xl p-8 transition-all
${isDragging
? 'border-blue-500 bg-blue-50/50 ring-4 ring-blue-100'
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
aria-label="文件上传区域"
>
<input
type="file"
ref={fileInputRef}
className="hidden"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(',')}
/>
<div className="flex flex-col items-center justify-center space-y-4">
<motion.div
animate={{ y: isDragging ? -10 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
>
<CloudArrowUpIcon className="w-16 h-16 text-blue-500/80" />
</motion.div>
<div className="text-center">
<p className="text-gray-500">{placeholder}</p>
</div>
<p className="text-xs text-gray-400 flex items-center gap-1">
<ExclamationCircleIcon className="w-4 h-4" />
: {allowedTypes.join(', ')} · : {maxSize}MB
</p>
</div>
</motion.div>
<AnimatePresence>
<div className="space-y-3">
{files.map(file => (
<FileItem
key={file.name}
file={file}
progress={progress[file.name]}
onRemove={removeFile}
/>
))}
</div>
</AnimatePresence>
</div>
)
} }
const FileItem: React.FC<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
{file.name}
</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{!isUploaded && progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">
{progress}%
</span>
</div>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div>
)}
</div>
</motion.div>
)
);
const FileUploader: React.FC<FileUploaderProps> = ({
endpoint = "",
onSuccess,
onError,
maxSize = 100,
placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ["*/*"],
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<
Array<{ file: File; isUploaded: boolean }>
>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
const handleError = (error: Error) => {
toast.error(error.message);
onError?.(error);
};
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 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(", ")}`
);
}
};
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 = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
setFiles((prev) => [
...prev,
...droppedFiles.map((file) => ({ file, isUploaded: false })),
]);
droppedFiles.forEach(uploadFile);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files);
setFiles((prev) => [
...prev,
...selectedFiles.map((file) => ({ file, isUploaded: false })),
]);
selectedFiles.forEach(uploadFile);
}
};
const removeFile = (fileName: string) => {
setFiles((prev) => prev.filter(({ file }) => file.name !== fileName));
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<div className="w-full space-y-4">
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out
${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(",")}
className="hidden"
/>
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium">
</p>
</div>
)}
</div>
<AnimatePresence>
<div className="space-y-3">
{files.map(({ file, isUploaded }) => (
<FileItem
key={file.name}
file={file}
progress={isUploaded ? 100 : progress}
onRemove={removeFile}
isUploaded={isUploaded}
/>
))}
</div>
</AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div>
)}
</div>
);
};
export default FileUploader;

View File

@ -0,0 +1,40 @@
import React from "react";
import { useTusUpload } from "@web/src/hooks/useTusUpload"; // 假设 useTusUpload 在同一个目录下
import * as tus from "tus-js-client";
interface TusUploadProps {
onSuccess?: (upload: tus.Upload) => void;
onError?: (error: Error) => void;
}
export const TusUploader: React.FC<TusUploadProps> = ({
onSuccess,
onError,
}) => {
const { progress, isUploading, uploadError, handleFileUpload } =
useTusUpload();
return (
<div>
<input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file, onSuccess, onError);
}}
/>
{isUploading && (
<div>
<progress value={progress} max="100" />
<span>{progress}%</span>
</div>
)}
{uploadError && (
<div style={{ color: "red" }}>: {uploadError}</div>
)}
</div>
);
};
export default TusUploader;

View File

@ -2,6 +2,8 @@ export const env: {
APP_NAME: string; APP_NAME: string;
SERVER_IP: string; SERVER_IP: string;
VERSION: string; VERSION: string;
UOLOAD_PORT: string;
SERVER_PORT: string;
} = { } = {
APP_NAME: import.meta.env.PROD APP_NAME: import.meta.env.PROD
? (window as any).env.VITE_APP_APP_NAME ? (window as any).env.VITE_APP_APP_NAME
@ -9,6 +11,12 @@ export const env: {
SERVER_IP: import.meta.env.PROD SERVER_IP: import.meta.env.PROD
? (window as any).env.VITE_APP_SERVER_IP ? (window as any).env.VITE_APP_SERVER_IP
: import.meta.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 VERSION: import.meta.env.PROD
? (window as any).env.VITE_APP_VERSION ? (window as any).env.VITE_APP_VERSION
: import.meta.env.VITE_APP_VERSION, : import.meta.env.VITE_APP_VERSION,

View File

@ -0,0 +1,125 @@
import { useState } from "react";
import * as tus from "tus-js-client";
import { env } from "../env";
import { getCompressedImageUrl } from "@nice/utils";
// useTusUpload.ts
interface UploadProgress {
fileId: string;
progress: number;
}
interface UploadResult {
compressedUrl: string;
url: string;
fileId: string;
}
export function useTusUpload() {
const [uploadProgress, setUploadProgress] = useState<
Record<string, number>
>({});
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const getFileId = (url: string) => {
const parts = url.split("/");
const uploadIndex = parts.findIndex((part) => part === "upload");
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
throw new Error("Invalid upload URL format");
}
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/");
};
const getResourceUrl = (url: string) => {
const parts = url.split("/");
const uploadIndex = parts.findIndex((part) => part === "upload");
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
throw new Error("Invalid upload URL format");
}
const resUrl = `http://${env.SERVER_IP}:${env.UOLOAD_PORT}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
return resUrl;
};
const handleFileUpload = async (
file: File,
onSuccess: (result: UploadResult) => void,
onError: (error: Error) => void,
fileKey: string // 添加文件唯一标识
) => {
// if (!file || !file.name || !file.type) {
// const error = new Error("不可上传该类型文件");
// setUploadError(error.message);
// onError(error);
// return;
// }
setIsUploading(true);
setUploadProgress((prev) => ({ ...prev, [fileKey]: 0 }));
setUploadError(null);
try {
const upload = new tus.Upload(file, {
endpoint: `http://${env.SERVER_IP}:${env.SERVER_PORT}/upload`,
retryDelays: [0, 1000, 3000, 5000],
metadata: {
filename: file.name,
filetype: file.type,
size: file.size as any,
},
onProgress: (bytesUploaded, bytesTotal) => {
const progress = Number(
((bytesUploaded / bytesTotal) * 100).toFixed(2)
);
setUploadProgress((prev) => ({
...prev,
[fileKey]: progress,
}));
},
onSuccess: async (payload) => {
try {
if (upload.url) {
const fileId = getFileId(upload.url);
const url = getResourceUrl(upload.url);
setIsUploading(false);
setUploadProgress((prev) => ({
...prev,
[fileKey]: 100,
}));
onSuccess({
compressedUrl: getCompressedImageUrl(url),
url,
fileId,
});
}
} catch (error) {
const err =
error instanceof Error
? error
: new Error("Unknown error");
setIsUploading(false);
setUploadError(err.message);
onError(err);
}
},
onError: (error) => {
setIsUploading(false);
setUploadError(error.message);
onError(error);
},
});
upload.start();
} catch (error) {
const err =
error instanceof Error ? error : new Error("Upload failed");
setIsUploading(false);
setUploadError(err.message);
onError(err);
}
};
return {
uploadProgress,
isUploading,
uploadError,
handleFileUpload,
};
}

View File

@ -1,56 +1,76 @@
import { api } from "../trpc"; import { api } from "../trpc";
export function useCourse() { // 定义返回类型
const utils = api.useUtils(); type UseCourseReturn = {
return { // Queries
// Queries findMany: typeof api.course.findMany.useQuery;
findMany: api.course.findMany.useQuery, findFirst: typeof api.course.findFirst.useQuery;
findFirst: api.course.findFirst.useQuery, findManyWithCursor: typeof api.course.findManyWithCursor.useQuery;
findManyWithCursor: api.course.findManyWithCursor.useQuery,
// Mutations // Mutations
create: api.course.create.useMutation({ create: ReturnType<any>;
onSuccess: () => { // create: ReturnType<typeof api.course.create.useMutation>;
utils.course.invalidate() update: ReturnType<any>;
utils.course.findMany.invalidate(); // update: ReturnType<typeof api.course.update.useMutation>;
utils.course.findManyWithCursor.invalidate(); createMany: ReturnType<typeof api.course.createMany.useMutation>;
utils.course.findManyWithPagination.invalidate() deleteMany: ReturnType<typeof api.course.deleteMany.useMutation>;
}, softDeleteByIds: ReturnType<any>;
}), // softDeleteByIds: ReturnType<typeof api.course.softDeleteByIds.useMutation>;
update: api.course.update.useMutation({ updateOrder: ReturnType<any>;
onSuccess: () => { // updateOrder: ReturnType<typeof api.course.updateOrder.useMutation>;
utils.course.findMany.invalidate(); };
utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() export function useCourse(): UseCourseReturn {
}, const utils = api.useUtils();
}), return {
createMany: api.course.createMany.useMutation({ // Queries
onSuccess: () => { findMany: api.course.findMany.useQuery,
utils.course.findMany.invalidate(); findFirst: api.course.findFirst.useQuery,
utils.course.findManyWithCursor.invalidate(); findManyWithCursor: api.course.findManyWithCursor.useQuery,
utils.course.findManyWithPagination.invalidate()
}, // Mutations
}), create: api.course.create.useMutation({
deleteMany: api.course.deleteMany.useMutation({ onSuccess: () => {
onSuccess: () => { utils.course.invalidate();
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
softDeleteByIds: api.course.softDeleteByIds.useMutation({ update: api.course.update.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate() utils.course.findManyWithPagination.invalidate();
}, },
}), }),
updateOrder: api.course.updateOrder.useMutation({ createMany: api.course.createMany.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.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();
},
}),
};
} }

View File

@ -50,35 +50,6 @@ export function useDepartment() {
return node; return node;
}); });
}; };
// const getTreeData = () => {
// const uniqueData: DepartmentDto[] = getCacheDataFromQuery(
// queryClient,
// api.department
// );
// const treeData: DataNode[] = buildTree(uniqueData);
// return treeData;
// };
// const getTreeData = () => {
// const cacheArray = queryClient.getQueriesData({
// queryKey: getQueryKey(api.department.getChildren),
// });
// const data: DepartmentDto[] = cacheArray
// .flatMap((cache) => cache.slice(1))
// .flat()
// .filter((item) => item !== undefined) as any;
// const uniqueDataMap = new Map<string, DepartmentDto>();
// data?.forEach((item) => {
// if (item && item.id) {
// uniqueDataMap.set(item.id, item);
// }
// });
// // Convert the Map back to an array
// const uniqueData: DepartmentDto[] = Array.from(uniqueDataMap.values());
// const treeData: DataNode[] = buildTree(uniqueData);
// return treeData;
// };
const getDept = <T = DepartmentDto>(key: string) => { const getDept = <T = DepartmentDto>(key: string) => {
return findQueryData<T>(queryClient, api.department, key); return findQueryData<T>(queryClient, api.department, key);
}; };

View File

@ -1,28 +1,20 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "esnext", "target": "esnext",
"module": "esnext", "module": "esnext",
"allowJs": true, "allowJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"lib": [ "lib": ["dom", "esnext"],
"dom", "jsx": "react-jsx",
"esnext" "declaration": true,
], "declarationMap": true,
"jsx": "react-jsx", "sourceMap": true,
"declaration": true, "outDir": "dist",
"declarationMap": true, "moduleResolution": "node",
"sourceMap": true, "incremental": true,
"outDir": "dist", "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo",
"moduleResolution": "node", },
"incremental": true, "include": ["src"],
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" "exclude": ["node_modules", "dist"]
},
"include": [
"src"
],
"exclude": [
"node_modules",
"dist"
],
} }

View File

@ -1,34 +1,35 @@
{ {
"name": "@nice/common", "name": "@nice/common",
"version": "1.0.0", "version": "1.0.0",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"generate": "pnpm prisma generate", "generate": "pnpm prisma generate",
"build": "pnpm generate && tsup", "build": "pnpm generate && tsup",
"dev": "pnpm generate && tsup --watch ", "dev": "pnpm generate && tsup --watch ",
"studio": "pnpm prisma studio", "dev-nowatch": "pnpm generate && tsup --no-watch ",
"db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init", "studio": "pnpm prisma studio",
"postinstall": "pnpm generate" "db:clear": "rm -rf prisma/migrations && pnpm prisma migrate dev --name init",
}, "postinstall": "pnpm generate"
"dependencies": { },
"@prisma/client": "5.17.0", "dependencies": {
"prisma": "5.17.0" "@prisma/client": "5.17.0",
}, "prisma": "5.17.0"
"peerDependencies": { },
"zod": "^3.23.8", "peerDependencies": {
"yjs": "^13.6.20", "zod": "^3.23.8",
"lib0": "^0.2.98" "yjs": "^13.6.20",
}, "lib0": "^0.2.98"
"devDependencies": { },
"@types/node": "^20.3.1", "devDependencies": {
"ts-node": "^10.9.1", "@types/node": "^20.3.1",
"typescript": "^5.5.4", "ts-node": "^10.9.1",
"concurrently": "^8.0.0", "typescript": "^5.5.4",
"tsup": "^8.3.5", "concurrently": "^8.0.0",
"rimraf": "^6.0.1" "tsup": "^8.3.5",
} "rimraf": "^6.0.1"
}
} }

View File

@ -74,16 +74,16 @@ export const courseDetailSelect: Prisma.CourseSelect = {
level: true, level: true,
requirements: true, requirements: true,
objectives: true, objectives: true,
skills: true, // skills: true,
audiences: true, // audiences: true,
totalDuration: true, // totalDuration: true,
totalLectures: true, // totalLectures: true,
averageRating: true, // averageRating: true,
numberOfReviews: true, // numberOfReviews: true,
numberOfStudents: true, // numberOfStudents: true,
completionRate: true, // completionRate: true,
status: true, status: true,
isFeatured: true, // isFeatured: true,
createdAt: true, createdAt: true,
publishedAt: true, publishedAt: true,
// 关联表选择 // 关联表选择

View File

@ -1,10 +1,18 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/index.ts"],
format: ['cjs', 'esm'], format: ["cjs", "esm"],
splitting: false, splitting: false,
sourcemap: true, sourcemap: true,
clean: false, clean: false,
dts: true dts: true,
// watch 可以是布尔值或字符串数组
watch: [
"src/**/*.ts",
"!src/**/*.test.ts",
"!src/**/*.spec.ts",
"!node_modules/**",
"!dist/**",
],
}); });

View File

@ -8,6 +8,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@ -4,20 +4,38 @@
* @returns ID字符串 * @returns ID字符串
*/ */
export function generateUniqueId(prefix?: string): string { 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' const environmentPart =
? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36) typeof window !== "undefined"
: require('crypto').randomBytes(4).toString('hex'); ? window.crypto.getRandomValues(new Uint32Array(1))[0].toString(36)
: require("crypto").randomBytes(4).toString("hex");
// 组合所有部分 // 组合所有部分
const uniquePart = `${timestamp}${randomPart}${environmentPart}`; const uniquePart = `${timestamp}${randomPart}${environmentPart}`;
// 如果提供了前缀,则添加前缀 // 如果提供了前缀,则添加前缀
return prefix ? `${prefix}_${uniquePart}` : uniquePart; return prefix ? `${prefix}_${uniquePart}` : uniquePart;
} }
export const formatFileSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
// 压缩图片路径生成函数
export const getCompressedImageUrl = (originalUrl: string): string => {
if (!originalUrl) {
return originalUrl;
}
const cleanUrl = originalUrl.split(/[?#]/)[0]; // 移除查询参数和哈希
const lastSlashIndex = cleanUrl.lastIndexOf("/");
return `${cleanUrl.slice(0, lastSlashIndex)}/compressed/${cleanUrl.slice(lastSlashIndex + 1).replace(/\.[^.]+$/, ".webp")}`;
};
export * from "./types";

View File

@ -0,0 +1 @@
export type NonVoid<T> = T extends void ? never : T;

View File

@ -296,6 +296,9 @@ importers:
'@nice/ui': '@nice/ui':
specifier: workspace:^ specifier: workspace:^
version: link:../../packages/ui version: link:../../packages/ui
'@nice/utils':
specifier: workspace:^
version: link:../../packages/utils
'@tanstack/query-async-storage-persister': '@tanstack/query-async-storage-persister':
specifier: ^5.51.9 specifier: ^5.51.9
version: 5.62.16 version: 5.62.16