This commit is contained in:
longdayi 2025-01-03 09:24:46 +08:00
parent cfa4be626d
commit 6f39331b70
92 changed files with 4057 additions and 870 deletions

2
.gitignore vendored
View File

@ -67,3 +67,5 @@ yarn-error.log*
# Ignore .idea files in the Expo monorepo # Ignore .idea files in the Expo monorepo
**/.idea/ **/.idea/
uploads

View File

@ -1,10 +1,13 @@
# 基础镜像 # 基础镜像
FROM node:20-alpine as base FROM node:20-alpine as base
# 更改 apk 镜像源为阿里云 # 更改 apk 镜像源为阿里云
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && \ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
yarn config set registry https://registry.npmmirror.com && \ # 设置 npm 镜像源
yarn global add pnpm && \ RUN yarn config set registry https://registry.npmmirror.com
pnpm config set registry https://registry.npmmirror.com
# 全局安装 pnpm 并设置其镜像源
RUN yarn global add pnpm && pnpm config set registry https://registry.npmmirror.com
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
@ -14,27 +17,25 @@ COPY pnpm-workspace.yaml ./
# 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件 # 首先复制 package.json, package-lock.json 和 pnpm-lock.yaml 文件
COPY package*.json pnpm-lock.yaml* ./ COPY package*.json pnpm-lock.yaml* ./
COPY tsconfig.base.json . COPY tsconfig.json .
# 利用 Docker 缓存机制,如果依赖没有改变则不会重新执行 pnpm install
#100-500 5-40
FROM base As server-build FROM base As server-build
WORKDIR /app WORKDIR /app
COPY packages/common /app/packages/common COPY packages/common /app/packages/common
COPY apps/server /app/apps/server COPY apps/server /app/apps/server
RUN pnpm install --filter common && \ RUN pnpm install --filter server
pnpm install --filter server && \ RUN pnpm install --filter common
pnpm --filter common generate && \ RUN pnpm --filter common generate && pnpm --filter common build:cjs
pnpm --filter common build:cjs && \ RUN pnpm --filter server build
pnpm --filter server build
FROM base As server-prod-dep FROM base As server-prod-dep
WORKDIR /app WORKDIR /app
COPY packages/common /app/packages/common COPY packages/common /app/packages/common
COPY apps/server /app/apps/server COPY apps/server /app/apps/server
RUN pnpm install --filter common --prod && \ RUN pnpm install --filter common --prod
pnpm install --filter server --prod && \ RUN pnpm install --filter server --prod
# 清理包管理器缓存
pnpm store prune && rm -rf /root/.npm && rm -rf /root/.cache
@ -44,8 +45,9 @@ ENV NODE_ENV production
COPY --from=server-build /app/packages/common/dist ./packages/common/dist COPY --from=server-build /app/packages/common/dist ./packages/common/dist
COPY --from=server-build /app/apps/server/dist ./apps/server/dist COPY --from=server-build /app/apps/server/dist ./apps/server/dist
COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh COPY apps/server/entrypoint.sh ./apps/server/entrypoint.sh
RUN chmod +x ./apps/server/entrypoint.sh RUN chmod +x ./apps/server/entrypoint.sh
# RUN apk add --no-cache postgresql-client RUN apk add --no-cache postgresql-client
EXPOSE 3000 EXPOSE 3000
@ -57,7 +59,9 @@ ENTRYPOINT [ "/app/apps/server/entrypoint.sh" ]
FROM base AS web-build FROM base AS web-build
# 复制其余文件到工作目录 # 复制其余文件到工作目录
COPY . . COPY . .
RUN pnpm install && pnpm --filter web build RUN pnpm install
RUN pnpm --filter web build
# 第二阶段,使用 nginx 提供服务 # 第二阶段,使用 nginx 提供服务
FROM nginx:stable-alpine as web FROM nginx:stable-alpine as web
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
@ -74,10 +78,6 @@ COPY apps/web/nginx.conf /etc/nginx/conf.d
COPY apps/web/entrypoint.sh /usr/bin/ COPY apps/web/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh RUN chmod +x /usr/bin/entrypoint.sh
# 安装 envsubst 以支持环境变量替换 # 安装 envsubst 以支持环境变量替换
# RUN apk add --no-cache envsubst
# RUN echo "http://mirrors.aliyun.com/alpine/v3.12/main/" > /etc/apk/repositories && \
# echo "http://mirrors.aliyun.com/alpine/v3.12/community/" >> /etc/apk/repositories && \
# apk update && \
RUN apk add --no-cache gettext RUN apk add --no-cache gettext
# 暴露 80 端口 # 暴露 80 端口
EXPOSE 80 EXPOSE 80
@ -85,37 +85,20 @@ EXPOSE 80
CMD ["/usr/bin/entrypoint.sh"] CMD ["/usr/bin/entrypoint.sh"]
FROM python:3.10-slim as aiservice FROM nginx:stable-alpine as nginx
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /usr/share/nginx/html
# 将 pip.conf 文件复制到镜像中 # 设置环境变量
# COPY apps/ai-service/config/pip.conf /etc/pip.conf
# 将 requirements.txt 复制到工作目录
COPY apps/ai-service/requirements.txt .
# 设置 pip 使用国内源,并安装依赖
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple && pip install --no-cache-dir -r requirements.txt
# 暴露端口
EXPOSE 8000
# 使用 entrypoint.sh 作为入口点
ENTRYPOINT ["/app/entrypoint.sh"]
FROM base As prisma-build
WORKDIR /app
COPY packages/common /app/packages/common
RUN pnpm install --filter common
RUN pnpm --filter common build
FROM base As prisma-prod-dep
WORKDIR /app
COPY packages/common /app/packages/common
RUN pnpm install --filter common --prod
FROM prisma-prod-dep as prisma-client
WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV production
COPY --from=prisma-build /app/packages/common/dist ./packages/common/dist
CMD ["pnpm", "prisma", "migrate", "deploy"] # 安装 envsubst 以支持环境变量替换
RUN apk add --no-cache gettext
# 暴露 80 端口
EXPOSE 80
# 可选:复制自定义的 nginx 配置
# COPY nginx.conf /etc/nginx/nginx.conf

View File

@ -11,3 +11,4 @@ DEADLINE_CRON="0 0 8 * * *"
SERVER_PORT=3000 SERVER_PORT=3000
ADMIN_PHONE_NUMBER=13258117304 ADMIN_PHONE_NUMBER=13258117304
NODE_ENV=development NODE_ENV=development
UPLOAD_DIR=/opt/projects/remooc/uploads

View File

@ -31,6 +31,9 @@
"@nestjs/websockets": "^10.3.10", "@nestjs/websockets": "^10.3.10",
"@nicestack/common": "workspace:*", "@nicestack/common": "workspace:*",
"@trpc/server": "11.0.0-rc.456", "@trpc/server": "11.0.0-rc.456",
"@tus/file-store": "^1.5.1",
"@tus/s3-store": "^1.6.2",
"@tus/server": "^1.10.0",
"argon2": "^0.41.1", "argon2": "^0.41.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"bullmq": "^5.12.0", "bullmq": "^5.12.0",
@ -38,16 +41,20 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"fluent-ffmpeg": "^2.1.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"lib0": "^0.2.97", "lib0": "^0.2.97",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"mime-types": "^2.1.35",
"minio": "^8.0.1", "minio": "^8.0.1",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.33.5",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"superjson-cjs": "^2.2.3", "superjson-cjs": "^2.2.3",
"transliteration": "^2.3.5",
"tus-js-client": "^4.1.0", "tus-js-client": "^4.1.0",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"ws": "^8.18.0", "ws": "^8.18.0",
@ -61,7 +68,9 @@
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/exceljs": "^1.3.0", "@types/exceljs": "^1.3.0",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/fluent-ffmpeg": "^2.1.27",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/mime-types": "^2.1.4",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",

View File

@ -17,6 +17,7 @@ import { CollaborationModule } from './socket/collaboration/collaboration.module
import { ExceptionsFilter } from './filters/exceptions.filter'; import { ExceptionsFilter } from './filters/exceptions.filter';
import { TransformModule } from './models/transform/transform.module'; import { TransformModule } from './models/transform/transform.module';
import { RealTimeModule } from './socket/realtime/realtime.module'; import { RealTimeModule } from './socket/realtime/realtime.module';
import { UploadModule } from './upload/upload.module';
@Module({ @Module({
imports: [ imports: [
@ -40,7 +41,8 @@ import { RealTimeModule } from './socket/realtime/realtime.module';
TransformModule, TransformModule,
MinioModule, MinioModule,
CollaborationModule, CollaborationModule,
RealTimeModule RealTimeModule,
UploadModule
], ],
providers: [{ providers: [{
provide: APP_FILTER, provide: APP_FILTER,

View File

@ -1,13 +1,59 @@
import { Controller, Post, Body, UseGuards, Get, Req } 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 '@nicestack/common'; import { AuthSchema, JwtPayload } from '@nicestack/common';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { UserProfileService } from './utils'; import { UserProfileService } from './utils';
import { z } from 'zod'; import { z } from 'zod';
import { FileValidationErrorType } from './types';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
private logger = new Logger(AuthController.name)
constructor(private readonly authService: AuthService) { } constructor(private readonly authService: AuthService) { }
@UseGuards(AuthGuard) @Get('file')
async authFileRequset(
@Headers('x-original-uri') originalUri: string,
@Headers('x-real-ip') realIp: string,
@Headers('x-original-method') method: string,
@Headers('x-query-params') queryParams: string,
@Headers('host') host: string,
@Headers('authorization') authorization: string,
) {
try {
const fileRequest = {
originalUri,
realIp,
method,
queryParams,
host,
authorization
};
const authResult = await this.authService.validateFileRequest(fileRequest);
if (!authResult.isValid) {
// 使用枚举类型进行错误处理
switch (authResult.error) {
case FileValidationErrorType.INVALID_URI:
throw new BadRequestException(authResult.error);
case FileValidationErrorType.RESOURCE_NOT_FOUND:
throw new NotFoundException(authResult.error);
case FileValidationErrorType.AUTHORIZATION_REQUIRED:
case FileValidationErrorType.INVALID_TOKEN:
throw new UnauthorizedException(authResult.error);
default:
throw new InternalServerErrorException(authResult.error || FileValidationErrorType.UNKNOWN_ERROR);
}
}
return {
headers: {
'X-User-Id': authResult.userId,
'X-Resource-Type': authResult.resourceType,
},
};
} catch (error: any) {
this.logger.verbose(`File request auth failed from ${realIp} reason:${error.message}`)
throw error;
}
}
@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;

View File

@ -6,15 +6,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { env } from '@server/env'; import { env } from '@server/env';
import { Request } from 'express';
import { JwtPayload } from '@nicestack/common'; import { JwtPayload } from '@nicestack/common';
import { extractTokenFromHeader } from './utils';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) { } constructor(private jwtService: JwtService) { }
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request); const token = extractTokenFromHeader(request);
if (!token) { if (!token) {
throw new UnauthorizedException(); throw new UnauthorizedException();
@ -36,8 +37,5 @@ export class AuthGuard implements CanActivate {
return true; return true;
} }
extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
} }

View File

@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { StaffModule } from '@server/models/staff/staff.module'; import { StaffModule } from '@server/models/staff/staff.module';
import { AuthRouter } from './auth.router';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
import { DepartmentService } from '@server/models/department/department.service'; import { DepartmentService } from '@server/models/department/department.service';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
@ -10,8 +9,8 @@ import { RoleMapModule } from '@server/models/rbac/rbac.module';
@Module({ @Module({
imports: [StaffModule, RoleMapModule], imports: [StaffModule, RoleMapModule],
providers: [AuthService, AuthRouter, TrpcService, DepartmentService, SessionService], providers: [AuthService, TrpcService, DepartmentService, SessionService],
exports: [AuthRouter, AuthService], exports: [AuthService],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule { } export class AuthModule { }

View File

@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { AuthSchema } from '@nicestack/common';
import { AuthService } from './auth.service';
@Injectable()
export class AuthRouter {
constructor(
private readonly trpc: TrpcService,
private readonly authService: AuthService,
) { }
router = this.trpc.router({
signUp: this.trpc.procedure
.input(AuthSchema.signUpRequest)
.mutation(async ({ input }) => {
const result = await this.authService.signUp(input);
return result;
}),
});
}

View File

@ -4,6 +4,7 @@ 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 {
@ -14,11 +15,12 @@ import {
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';
import { UserProfileService } from './utils'; import { extractTokenFromAuthorization, UserProfileService } from './utils';
import { SessionInfo, SessionService } from './session.service'; import { SessionInfo, SessionService } from './session.service';
import { tokenConfig } from './config'; import { tokenConfig } from './config';
import { z } from 'zod'; import { z } from 'zod';
import { FileAuthResult, FileRequest, FileValidationErrorType } from './types';
import { extractFilePathFromUri } from '@server/utils/file';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private logger = new Logger(AuthService.name) private logger = new Logger(AuthService.name)
@ -28,6 +30,46 @@ export class AuthService {
private readonly sessionService: SessionService, private readonly sessionService: SessionService,
) { } ) { }
async validateFileRequest(params: FileRequest): Promise<FileAuthResult> {
try {
// 基础参数验证
if (!params?.originalUri) {
return { isValid: false, error: FileValidationErrorType.INVALID_URI };
}
const fileId = extractFilePathFromUri(params.originalUri);
const resource = await db.resource.findFirst({ where: { fileId } });
// 资源验证
if (!resource) {
return { isValid: false, error: FileValidationErrorType.RESOURCE_NOT_FOUND };
}
// 处理公开资源
if (resource.isPublic) {
return {
isValid: true,
resourceType: resource.type || 'unknown'
};
}
// 处理私有资源
const token = extractTokenFromAuthorization(params.authorization);
if (!token) {
return { isValid: false, error: FileValidationErrorType.AUTHORIZATION_REQUIRED };
}
const payload: JwtPayload = await this.jwtService.verify(token)
if (!payload.sub) {
return { isValid: false, error: FileValidationErrorType.INVALID_TOKEN };
}
return {
isValid: true,
userId: payload.sub,
resourceType: resource.type || 'unknown'
};
} catch (error) {
this.logger.error('File validation error:', error);
return { isValid: false, error: FileValidationErrorType.UNKNOWN_ERROR };
}
}
private async generateTokens(payload: JwtPayload): Promise<{ private async generateTokens(payload: JwtPayload): Promise<{
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;

View File

@ -7,3 +7,25 @@ export interface TokenConfig {
expirationMs: number; expirationMs: number;
}; };
} }
export interface FileAuthResult {
isValid: boolean
userId?: string
resourceType?: string
error?: string
}
export interface FileRequest {
originalUri: string;
realIp: string;
method: string;
queryParams: string;
host: string;
authorization: string
}
export enum FileValidationErrorType {
INVALID_URI = 'INVALID_URI',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN',
UNKNOWN_ERROR = 'UNKNOWN_ERROR'
}

View File

@ -11,7 +11,7 @@ import { env } from '@server/env';
import { redis } from '@server/utils/redis/redis.service'; import { redis } from '@server/utils/redis/redis.service';
import EventBus from '@server/utils/event-bus'; import EventBus from '@server/utils/event-bus';
import { RoleMapService } from '@server/models/rbac/rolemap.service'; import { RoleMapService } from '@server/models/rbac/rolemap.service';
import { Request } from "express"
interface ProfileResult { interface ProfileResult {
staff: UserProfile | undefined; staff: UserProfile | undefined;
error?: string; error?: string;
@ -21,7 +21,14 @@ interface TokenVerifyResult {
id?: string; id?: string;
error?: string; error?: string;
} }
export function extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = extractTokenFromAuthorization(request.headers.authorization)
return type === 'Bearer' ? token : undefined;
}
export function extractTokenFromAuthorization(authorization: string): string | undefined {
const [type, token] = authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
export class UserProfileService { export class UserProfileService {
public static readonly instance = new UserProfileService(); public static readonly instance = new UserProfileService();

View File

@ -437,16 +437,25 @@ export class BaseService<
pageSize?: number; pageSize?: number;
where?: WhereArgs<A['findMany']>; where?: WhereArgs<A['findMany']>;
select?: SelectArgs<A['findMany']> select?: SelectArgs<A['findMany']>
}): Promise<R['findMany']> { }): Promise<{ items: R['findMany']; totalPages: number }> {
const { page = 1, pageSize = 10, where, select } = args; const { page = 1, pageSize = 10, where, select } = args;
try { try {
return this.getModel().findMany({ // 获取总记录数
const total = await this.getModel().count({ where }) as number;
// 获取分页数据
const items = await this.getModel().findMany({
where, where,
select, select,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
} as any) as R['findMany'];
} as any) as Promise<R['findMany']>; // 计算总页数
const totalPages = Math.ceil(total / pageSize);
return {
items,
totalPages
};
} catch (error) { } catch (error) {
this.handleError(error, 'read'); this.handleError(error, 'read');
} }

View File

@ -23,13 +23,13 @@ export class CourseRouter {
.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); return await this.courseService.create(input, { staff });
}), }),
update: this.trpc.protectProcedure update: this.trpc.protectProcedure
.input(CourseUpdateArgsSchema) .input(CourseUpdateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.courseService.update(input, staff); return await this.courseService.update(input, { staff });
}), }),
createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema)) createMany: this.trpc.protectProcedure.input(z.array(CourseCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -74,7 +74,7 @@ export class CourseRouter {
}), }),
findManyWithPagination: this.trpc.procedure findManyWithPagination: this.trpc.procedure
.input(z.object({ .input(z.object({
page: z.number(), page: z.number().optional(),
pageSize: z.number().optional(), pageSize: z.number().optional(),
where: CourseWhereInputSchema.optional(), where: CourseWhereInputSchema.optional(),
select: CourseSelectSchema.optional() select: CourseSelectSchema.optional()

View File

@ -5,10 +5,74 @@ import {
db, db,
ObjectType, ObjectType,
Prisma, Prisma,
InstructorRole,
} from '@nicestack/common'; } from '@nicestack/common';
@Injectable() @Injectable()
export class CourseService extends BaseService<Prisma.CourseDelegate> { export class CourseService extends BaseService<Prisma.CourseDelegate> {
constructor() { constructor() {
super(db, ObjectType.COURSE); super(db, ObjectType.COURSE);
} }
async create(
args: Prisma.CourseCreateArgs,
params?: { staff?: UserProfile }
) {
return await db.$transaction(async tx => {
const result = await super.create(args, { tx });
if (params?.staff?.id) {
await tx.courseInstructor.create({
data: {
instructorId: params.staff.id,
courseId: result.id,
role: InstructorRole.MAIN,
}
});
}
return result;
}, {
timeout: 10000 // 10 seconds
});
}
async update(
args: Prisma.CourseUpdateArgs,
params?: { staff?: UserProfile }
) {
return await db.$transaction(async tx => {
const result = await super.update(args, { tx });
return result;
}, {
timeout: 10000 // 10 seconds
});
}
async removeInstructor(courseId: string, instructorId: string) {
return await db.courseInstructor.delete({
where: {
courseId_instructorId: {
courseId,
instructorId,
},
},
});
}
async addInstructor(params: {
courseId: string;
instructorId: string;
role?: string;
order?: number;
}) {
return await db.courseInstructor.create({
data: {
courseId: params.courseId,
instructorId: params.instructorId,
role: params.role || InstructorRole.ASSISTANT,
order: params.order,
},
});
}
async getInstructors(courseId: string) {
return await db.courseInstructor.findMany({
where: { courseId },
include: { instructor: true },
orderBy: { order: 'asc' },
});
}
} }

View File

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

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const EnrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
});
export const UnenrollSchema = z.object({
studentId: z.string(),
courseId: z.string(),
});

View File

@ -3,6 +3,7 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nicestack/common'; import { Prisma, UpdateOrderSchema } from '@nicestack/common';
import { EnrollmentService } from './enrollment.service'; import { EnrollmentService } from './enrollment.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
const EnrollmentCreateArgsSchema: ZodType<Prisma.EnrollmentCreateArgs> = z.any() const EnrollmentCreateArgsSchema: ZodType<Prisma.EnrollmentCreateArgs> = z.any()
const EnrollmentCreateManyInputSchema: ZodType<Prisma.EnrollmentCreateManyInput> = z.any() const EnrollmentCreateManyInputSchema: ZodType<Prisma.EnrollmentCreateManyInput> = z.any()
const EnrollmentDeleteManyArgsSchema: ZodType<Prisma.EnrollmentDeleteManyArgs> = z.any() const EnrollmentDeleteManyArgsSchema: ZodType<Prisma.EnrollmentDeleteManyArgs> = z.any()
@ -18,38 +19,11 @@ export class EnrollmentRouter {
private readonly enrollmentService: EnrollmentService, private readonly enrollmentService: EnrollmentService,
) { } ) { }
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure
.input(EnrollmentCreateArgsSchema)
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.create(input, staff);
}),
createMany: this.trpc.protectProcedure.input(z.array(EnrollmentCreateManyInputSchema))
.mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.enrollmentService.createMany({ data: input }, staff);
}),
deleteMany: this.trpc.procedure
.input(EnrollmentDeleteManyArgsSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.deleteMany(input);
}),
findFirst: this.trpc.procedure findFirst: this.trpc.procedure
.input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .input(EnrollmentFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.enrollmentService.findFirst(input); return await this.enrollmentService.findFirst(input);
}), }),
softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => {
return this.enrollmentService.softDeleteByIds(input.ids);
}),
updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema)
.mutation(async ({ input }) => {
return this.enrollmentService.updateOrder(input);
}),
findMany: this.trpc.procedure findMany: this.trpc.procedure
.input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .input(EnrollmentFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
@ -66,5 +40,15 @@ export class EnrollmentRouter {
const { staff } = ctx; const { staff } = ctx;
return await this.enrollmentService.findManyWithCursor(input); return await this.enrollmentService.findManyWithCursor(input);
}), }),
enroll: this.trpc.protectProcedure
.input(EnrollSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.enroll(input);
}),
unenroll: this.trpc.protectProcedure
.input(UnenrollSchema)
.mutation(async ({ input }) => {
return await this.enrollmentService.unenroll(input);
}),
}); });
} }

View File

@ -1,17 +1,74 @@
import { Injectable } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BaseService } from '../base/base.service'; import { BaseService } from '../base/base.service';
import { import {
UserProfile, UserProfile,
db, db,
ObjectType, ObjectType,
Prisma, Prisma,
EnrollmentStatus
} from '@nicestack/common'; } from '@nicestack/common';
import { z } from 'zod';
import { EnrollSchema, UnenrollSchema } from './enroll.schema';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable() @Injectable()
export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> { export class EnrollmentService extends BaseService<Prisma.EnrollmentDelegate> {
constructor() { constructor() {
super(db, ObjectType.COURSE); super(db, ObjectType.COURSE);
} }
async enroll(params: z.infer<typeof EnrollSchema>) {
const { studentId, courseId } = params
const result = await db.$transaction(async tx => {
// 检查是否已经报名
const existing = await tx.enrollment.findUnique({
where: {
studentId_courseId: {
studentId: studentId,
courseId: courseId,
},
},
});
if (existing) {
throw new ConflictException('Already enrolled in this course');
}
// 创建报名记录
const enrollment = await tx.enrollment.create({
data: {
studentId: studentId,
courseId: courseId,
status: EnrollmentStatus.ACTIVE,
},
});
return enrollment;
});
EventBus.emit('dataChanged', {
type: ObjectType.ENROLLMENT,
operation: CrudOperation.CREATED,
data: result,
});
return result
}
async unenroll(params: z.infer<typeof UnenrollSchema>) {
const { studentId, courseId } = params
const result = await db.enrollment.update({
where: {
studentId_courseId: {
studentId,
courseId,
},
},
data: {
status: EnrollmentStatus.CANCELLED
}
});
EventBus.emit('dataChanged', {
type: ObjectType.ENROLLMENT,
operation: CrudOperation.UPDATED,
data: result,
});
return result
}
} }

View File

@ -22,7 +22,7 @@ export class LectureRouter {
.input(LectureCreateArgsSchema) .input(LectureCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.lectureService.create(input, staff); return await this.lectureService.create(input, {staff});
}), }),
createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema)) createMany: this.trpc.protectProcedure.input(z.array(LectureCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {

View File

@ -4,14 +4,32 @@ import {
UserProfile, UserProfile,
db, db,
ObjectType, ObjectType,
Prisma, Prisma
} from '@nicestack/common'; } from '@nicestack/common';
import EventBus, { CrudOperation } from '@server/utils/event-bus';
@Injectable() @Injectable()
export class LectureService extends BaseService<Prisma.LectureDelegate> { export class LectureService extends BaseService<Prisma.LectureDelegate> {
constructor() { constructor() {
super(db, ObjectType.COURSE); super(db, ObjectType.COURSE);
} }
async create(args: Prisma.LectureCreateArgs, params?: { staff?: UserProfile }) {
const result = await super.create(args)
EventBus.emit('dataChanged', {
type: ObjectType.LECTURE,
operation: CrudOperation.CREATED,
data: result,
});
return result;
}
async update(args: Prisma.LectureUpdateArgs) {
const result = await super.update(args);
EventBus.emit('dataChanged', {
type: ObjectType.LECTURE,
operation: CrudOperation.UPDATED,
data: result,
});
return result;
}
} }

View File

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

View File

@ -39,12 +39,17 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
} }
async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) { async update(args: Prisma.PostUpdateArgs, staff?: UserProfile) {
args.data.authorId = staff?.id; args.data.authorId = staff?.id;
return super.update(args); const result = await super.update(args);
EventBus.emit('dataChanged', {
type: ObjectType.POST,
operation: CrudOperation.UPDATED,
data: result,
});
return result
} }
async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) { async findManyWithCursor(args: Prisma.PostFindManyArgs, staff?: UserProfile) {
if (!args.where) args.where = {} if (!args.where) args.where = {}
args.where.OR = await this.preFilter(args.where.OR, staff); args.where.OR = await this.preFilter(args.where.OR, staff);
// console.log(`findwithcursor_post ${JSON.stringify(args.where)}`)
return this.wrapResult(super.findManyWithCursor(args), async (result) => { return this.wrapResult(super.findManyWithCursor(args), async (result) => {
let { items } = result; let { items } = result;
await Promise.all( await Promise.all(

View File

@ -0,0 +1,84 @@
import { PrismaClient, Resource } from '@prisma/client'
import { ProcessResult, ResourceProcessor } from '../types'
import { db, ResourceProcessStatus } from '@nicestack/common'
import { Logger } from '@nestjs/common';
// Pipeline 类
export class ResourceProcessingPipeline {
private processors: ResourceProcessor[] = []
private logger = new Logger(ResourceProcessingPipeline.name);
constructor() { }
// 添加处理器
addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline {
this.processors.push(processor)
return this
}
// 执行处理管道
async execute(resource: Resource): Promise<ProcessResult> {
let currentResource = resource
try {
this.logger.log(`开始处理资源: ${resource.id}`)
currentResource = await this.updateProcessStatus(
resource.id,
ResourceProcessStatus.PROCESSING
)
this.logger.log(`资源状态已更新为处理中`)
for (const processor of this.processors) {
const processorName = processor.constructor.name
this.logger.log(`开始执行处理器: ${processorName}`)
currentResource = await this.updateProcessStatus(
currentResource.id,
processor.constructor.name as ResourceProcessStatus
)
currentResource = await processor.process(currentResource)
this.logger.log(`处理器 ${processorName} 执行完成`)
currentResource = await db.resource.update({
where: { id: currentResource.id },
data: currentResource
})
}
currentResource = await this.updateProcessStatus(
currentResource.id,
ResourceProcessStatus.SUCCESS
)
this.logger.log(`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`)
return {
success: true,
resource: currentResource
}
} catch (error) {
this.logger.error(`资源 ${resource.id} 处理失败:`, error)
currentResource = await this.updateProcessStatus(
currentResource.id,
ResourceProcessStatus.FAILED
)
return {
success: false,
resource: currentResource,
error: error as Error
}
}
}
private async updateProcessStatus(
resourceId: string,
processStatus: ResourceProcessStatus
): Promise<Resource> {
return db.resource.update({
where: { id: resourceId },
data: { processStatus }
})
}
}

View File

@ -0,0 +1,52 @@
import { BaseMetadata, FileMetadata, ResourceProcessor } from "../types";
import { Resource, db, ResourceProcessStatus } from "@nicestack/common";
import { extname } from "path";
import mime from "mime";
import { calculateFileHash, getUploadFilePath } from "@server/utils/file";
import { Logger } from "@nestjs/common";
import { statSync } from "fs"; // Add this import
export class GeneralProcessor implements ResourceProcessor {
private logger = new Logger(GeneralProcessor.name);
async process(resource: Resource): Promise<Resource> {
const originMeta = resource.metadata as any;
try {
// 提取文件扩展名作为type
const fileExtension = extname(resource.filename).toLowerCase().slice(1);
this.logger.debug(`文件扩展名: ${fileExtension}`);
// 获取文件路径并验证
const filePath = getUploadFilePath(resource.fileId);
this.logger.debug(`文件路径: ${filePath}`);
const fileStats = statSync(filePath);
const fileSize = fileStats.size;
const modifiedAt = fileStats.mtime;
const createdAt = fileStats.birthtime;
const fileHash = await calculateFileHash(filePath);
// 准备metadata
const metadata: BaseMetadata = {
filename: resource.filename,
extension: fileExtension,
mimeType: mime.lookup(fileExtension) || 'application/octet-stream',
size: fileSize,
modifiedAt,
createdAt,
};
const updatedResource = await db.resource.update({
where: { id: resource.id },
data: {
type: fileExtension,
hash: fileHash,
metadata: { ...originMeta, ...metadata } as any,
}
});
return updatedResource;
} catch (error: any) {
throw new Error(`Resource processing failed: ${error.message}`);
}
}
}

View File

@ -0,0 +1,65 @@
import path from "path";
import sharp from 'sharp';
import { FileMetadata, ImageMetadata, ResourceProcessor } from "../types";
import { Resource, ResourceProcessStatus, db } from "@nicestack/common";
import { getUploadFilePath } from "@server/utils/file";
import { Logger } from "@nestjs/common";
import { promises as fsPromises } from 'fs';
export class ImageProcessor implements ResourceProcessor {
private logger = new Logger(ImageProcessor.name)
constructor() { }
async process(resource: Resource): Promise<Resource> {
const { fileId } = resource;
const filepath = getUploadFilePath(fileId);
const originMeta = resource.metadata as unknown as FileMetadata;
if (!originMeta.mimeType?.startsWith('image/')) {
this.logger.log(`Skipping non-image resource: ${resource.id}`);
return resource;
}
try {
const image = sharp(filepath);
const metadata = await image.metadata();
if (!metadata) {
throw new Error(`Failed to get metadata for image: ${fileId}`);
}
// Create WebP compressed version
const compressedPath = path.join(
path.dirname(filepath),
`${path.basename(filepath, path.extname(filepath))}_compressed.webp`
);
await image
.webp({
quality: 80,
lossless: false,
effort: 5 // Range 0-6, higher means slower but better compression
})
.toFile(compressedPath);
const imageMeta: ImageMetadata = {
width: metadata.width || 0,
height: metadata.height || 0,
compressedUrl: path.basename(compressedPath),
orientation: metadata.orientation,
space: metadata.space,
hasAlpha: metadata.hasAlpha,
}
console.log(imageMeta)
const updatedResource = await db.resource.update({
where: { id: resource.id },
data: {
metadata: {
...originMeta,
...imageMeta
}
}
});
return updatedResource;
} catch (error: any) {
throw new Error(`Failed to process image: ${error.message}`);
}
}
}

View File

@ -0,0 +1,9 @@
// import ffmpeg from 'fluent-ffmpeg';
// import { ResourceProcessor } from '../types';
// import { Resource } from '@nicestack/common';
// export class VideoProcessor implements ResourceProcessor {
// async process(resource: Resource): Promise<Resource> {
// }
// }

View File

@ -22,7 +22,7 @@ export class ResourceRouter {
.input(ResourceCreateArgsSchema) .input(ResourceCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.resourceService.create(input, staff); return await this.resourceService.create(input, { staff });
}), }),
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema)) createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {

View File

@ -5,13 +5,34 @@ import {
db, db,
ObjectType, ObjectType,
Prisma, Prisma,
Resource,
} from '@nicestack/common'; } from '@nicestack/common';
import { createHash } from 'crypto';
@Injectable() @Injectable()
export class ResourceService extends BaseService<Prisma.ResourceDelegate> { export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
constructor() { constructor() {
super(db, ObjectType.RESOURCE); super(db, ObjectType.RESOURCE);
} }
async create(
args: Prisma.ResourceCreateArgs,
params?: { staff?: UserProfile },
): Promise<Resource> {
if (params?.staff) {
args.data.ownerId = params?.staff?.id
}
return this.create(args);
}
async checkFileExists(hash: string): Promise<Resource | null> {
return this.findFirst({
where: {
hash,
deletedAt: null,
},
});
}
async calculateFileHash(buffer: Buffer): Promise<string> {
return createHash('sha256').update(buffer).digest('hex');
}
} }

View File

@ -0,0 +1,57 @@
import { Resource } from "@nicestack/common";
export interface ResourceProcessor {
process(resource: Resource): Promise<any>
}// 处理结果
export interface ProcessResult {
success: boolean
resource: Resource
error?: Error
}
export interface BaseMetadata {
size: number
mimeType: string
filename: string
extension: string
modifiedAt: Date
createdAt: Date
}
/**
*
*/
export interface ImageMetadata {
width: number; // 图片宽度(px)
height: number; // 图片高度(px)
compressedUrl?:string;
orientation?: number; // EXIF方向信息
space?: string; // 色彩空间 (如: RGB, CMYK)
hasAlpha?: boolean; // 是否包含透明通道
}
/**
*
*/
export interface VideoMetadata {
width: number; // 视频宽度(px)
height: number; // 视频高度(px)
duration: number; // 视频时长(秒)
thumbnail?: string; // 视频封面图URL
codec?: string; // 视频编码格式
frameRate?: number; // 帧率(fps)
bitrate?: number; // 比特率(bps)
}
/**
*
*/
export interface AudioMetadata {
duration: number; // 音频时长(秒)
bitrate?: number; // 比特率(bps)
sampleRate?: number; // 采样率(Hz)
channels?: number; // 声道数
codec?: string; // 音频编码格式
}
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata

View File

@ -22,7 +22,7 @@ export class SectionRouter {
.input(SectionCreateArgsSchema) .input(SectionCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.sectionService.create(input, staff); return await this.sectionService.create(input, { staff });
}), }),
createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema)) createMany: this.trpc.protectProcedure.input(z.array(SectionCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {

View File

@ -13,5 +13,12 @@ export class SectionService extends BaseService<Prisma.SectionDelegate> {
super(db, ObjectType.SECTION); super(db, ObjectType.SECTION);
} }
create(args: Prisma.SectionCreateArgs, params?: { staff?: UserProfile }) {
return super.create(args)
}
async update(args: Prisma.SectionUpdateArgs) {
return super.update(args);
}
} }

View File

@ -1,4 +0,0 @@
export type CustomJobType = "pushMessage" | "updateTroubleViewCount"
export type updateViewCountJobData = {
id: string
}

View File

@ -1,53 +0,0 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { Queue } from "bullmq";
import { db, getUniqueItems, MessageDto, ObjectType } from "@nicestack/common"
import { MessageContent } from "./push.service";
import EventBus, { CrudOperation } from "@server/utils/event-bus";
export interface PushMessageJobData {
id: string
registerToken: string
messageContent: MessageContent
}
@Injectable()
export class PushQueueService implements OnModuleInit {
private readonly logger = new Logger(PushQueueService.name)
constructor(@InjectQueue('general') private generalQueue: Queue) { }
onModuleInit() {
EventBus.on("dataChanged", async ({ data, type, operation }) => {
if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
const message = data as MessageDto
const uniqueStaffs = getUniqueItems(message.receivers, "id")
uniqueStaffs.forEach(item => {
const token = item.registerToken
if (token) {
this.addPushMessageJob({
registerToken: token,
messageContent: {
data: {
title: message.title!,
content: message.content!,
click_action: {
intent: message.intent!,
url: message.url!
}
},
option: message.option as any
},
id: message.id
})
} else {
this.logger.warn(`用户 ${item.username} 尚未注册registerToken取消消息推送`)
}
})
}
})
}
async addPushMessageJob(data: PushMessageJobData) {
this.logger.log("add push message task", data.registerToken)
await this.generalQueue.add('pushMessage', data, { debounce: { id: data.id } })
}
}

View File

@ -1,120 +0,0 @@
import { Injectable, InternalServerErrorException, BadRequestException, UnauthorizedException, NotFoundException } from '@nestjs/common';
import axios, { AxiosResponse } from 'axios';
interface LoginResponse {
retcode: string;
message: string;
authtoken?: string;
}
interface MessagePushResponse {
retcode: string;
message: string;
messageid?: string;
}
interface Notification {
title?: string; // 通知标题不超过128字节 / Title of notification (upper limit is 128 bytes)
content?: string; // 通知内容不超过256字节 / Content of notification (upper limit is 256 bytes)
click_action?: {
url?: string; // 点击通知栏消息打开指定的URL地址 / URL to open when notification is clicked
intent?: string; // 点击通知栏消息用户收到通知栏消息后点击通知栏消息打开应用定义的这个Intent页面 / Intent page to open in the app when notification is clicked
};
}
interface Option {
key: string;
value: string;
}
export interface MessageContent {
data: Notification;
option?: Option;
}
@Injectable()
export class PushService {
private readonly baseURL = process.env.PUSH_URL;
private readonly appid = process.env.PUSH_APPID;
private readonly appsecret = process.env.PUSH_APPSECRET;
private authToken: string | null = null;
async login(): Promise<LoginResponse> {
if (this.authToken) {
return { retcode: '200', message: 'Already logged in', authtoken: this.authToken };
}
const url = `${this.baseURL}/push/1.0/login`;
const response: AxiosResponse<LoginResponse> = await axios.post(url, {
appid: this.appid,
appsecret: this.appsecret,
});
this.handleError(response.data.retcode);
this.authToken = response.data.authtoken!;
return response.data;
}
async messagePush(
registerToken: string,
messageContent: MessageContent,
): Promise<MessagePushResponse> {
if (!this.authToken) {
await this.login();
}
const url = `${this.baseURL}/push/1.0/messagepush`;
const payload = {
appid: this.appid,
appsecret: this.appsecret,
authtoken: this.authToken,
registertoken: registerToken,
messagecontent: JSON.stringify(messageContent),
};
const response: AxiosResponse<MessagePushResponse> = await axios.post(url, payload);
this.handleError(response.data.retcode);
return response.data;
}
private handleError(retcode: string): void {
switch (retcode) {
case '000':
case '200':
return;
case '001':
throw new BadRequestException('JID is illegal');
case '002':
throw new BadRequestException('AppID is illegal');
case '003':
throw new BadRequestException('Protoversion is mismatch');
case '010':
throw new BadRequestException('The application of AppID Token is repeated');
case '011':
throw new BadRequestException('The number of applications for token exceeds the maximum');
case '012':
throw new BadRequestException('Token is illegal');
case '013':
throw new UnauthorizedException('Integrity check failed');
case '014':
throw new BadRequestException('Parameter is illegal');
case '015':
throw new InternalServerErrorException('Internal error');
case '202':
throw new UnauthorizedException('You are already logged in');
case '205':
return;
case '500':
throw new InternalServerErrorException('Internal Server Error');
case '502':
throw new InternalServerErrorException('Session loading error');
case '503':
throw new InternalServerErrorException('Service Unavailable');
case '504':
throw new NotFoundException('Parameters not found');
case '505':
throw new BadRequestException('Parameters are empty or not as expected');
case '506':
throw new InternalServerErrorException('Database error');
case '508':
throw new InternalServerErrorException('NoSuchAlgorithmException');
case '509':
throw new UnauthorizedException('Authentication Failed');
case '510':
throw new UnauthorizedException('Illegal token... client does not exist');
default:
throw new InternalServerErrorException('Unexpected error occurred');
}
}
}

View File

@ -2,8 +2,6 @@ import { BullModule } from '@nestjs/bullmq';
import { Logger, Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'path'; import { join } from 'path';
import { PushService } from './push/push.service';
import { PushQueueService } from './push/push.queue.service';
@Module({ @Module({
imports: [ imports: [
@ -19,13 +17,19 @@ import { PushQueueService } from './push/push.queue.service';
}), }),
inject: [ConfigService], inject: [ConfigService],
}), }),
BullModule.registerQueue({ BullModule.registerQueue(
{
name: 'general', name: 'general',
processors: [join(__dirname, 'worker/processor.js')], processors: [join(__dirname, 'worker/processor.js')],
}) },
{
name: 'file-queue', // 新增文件处理队列
processors: [join(__dirname, 'worker/file.processor.js')], // 文件处理器的路径
}
),
], ],
providers: [Logger, PushService, PushQueueService], providers: [Logger],
exports: [PushService, PushQueueService] exports: []
}) })
export class QueueModule { } export class QueueModule { }

View File

@ -0,0 +1,70 @@
import { InjectQueue } from "@nestjs/bullmq";
import { Injectable } from "@nestjs/common";
import EventBus from "@server/utils/event-bus";
import { Queue } from "bullmq";
import { ObjectType } from "@nicestack/common";
import { QueueJobType } from "../types";
@Injectable()
export class StatsService {
constructor(
@InjectQueue('general') private generalQueue: Queue
) {
EventBus.on("dataChanged", async ({ type, data }) => {
const jobOptions = {
removeOnComplete: true,
jobId: this.generateJobId(type as ObjectType, data) // 使用唯一ID防止重复任务
};
switch (type) {
case ObjectType.ENROLLMENT:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.ENROLLMENT
},
jobOptions
);
break;
case ObjectType.LECTURE:
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
sectionId: data.sectionId,
courseId: data.courseId,
type: ObjectType.LECTURE
},
jobOptions
);
break;
case ObjectType.POST:
if (data.courseId) {
await this.generalQueue.add(
QueueJobType.UPDATE_STATS,
{
courseId: data.courseId,
type: ObjectType.POST
},
jobOptions
);
}
break;
}
});
}
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()}`;
}
}
}

4
apps/server/src/queue/types.ts Executable file
View File

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

View File

@ -0,0 +1,26 @@
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';
import { QueueJobType } from '../types';
import { ResourceProcessingPipeline } from '@server/models/resource/pipe/resource.pipeline';
import { GeneralProcessor } from '@server/models/resource/processor/GeneralProcessor';
import { ImageProcessor } from '@server/models/resource/processor/ImageProcessor';
import superjson from 'superjson-cjs';
import { Resource } from '@nicestack/common';
const logger = new Logger('FileProcessorWorker');
const pipeline = new ResourceProcessingPipeline()
.addProcessor(new GeneralProcessor())
.addProcessor(new ImageProcessor())
export default async function processJob(job: Job<any, any, QueueJobType>) {
if (job.name === QueueJobType.FILE_PROCESS) {
console.log(job)
const { resource } = job.data;
if (!resource) {
throw new Error('No resource provided in job data');
}
const result = await pipeline.execute(resource);
return result;
}
}

View File

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

View File

@ -5,7 +5,6 @@ import { TaxonomyRouter } from '@server/models/taxonomy/taxonomy.router';
import { TermRouter } from '@server/models/term/term.router'; import { TermRouter } from '@server/models/term/term.router';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express'; import * as trpcExpress from '@trpc/server/adapters/express';
import { AuthRouter } from '@server/auth/auth.router';
import ws, { WebSocketServer } from 'ws'; import ws, { WebSocketServer } from 'ws';
import { AppConfigRouter } from '@server/models/app-config/app-config.router'; import { AppConfigRouter } from '@server/models/app-config/app-config.router';
import { MessageRouter } from '@server/models/message/message.router'; import { MessageRouter } from '@server/models/message/message.router';
@ -30,7 +29,6 @@ export class TrpcRouter {
private readonly role: RoleRouter, private readonly role: RoleRouter,
private readonly rolemap: RoleMapRouter, private readonly rolemap: RoleMapRouter,
private readonly transform: TransformRouter, private readonly transform: TransformRouter,
private readonly auth: AuthRouter,
private readonly app_config: AppConfigRouter, private readonly app_config: AppConfigRouter,
private readonly message: MessageRouter, private readonly message: MessageRouter,
private readonly visitor: VisitRouter, private readonly visitor: VisitRouter,
@ -40,7 +38,7 @@ export class TrpcRouter {
// private readonly websocketService: WebSocketService // private readonly websocketService: WebSocketService
) { } ) { }
appRouter = this.trpc.router({ appRouter = this.trpc.router({
auth: this.auth.router,
transform: this.transform.router, transform: this.transform.router,
post: this.post.router, post: this.post.router,
department: this.department.router, department: this.department.router,

View File

@ -0,0 +1,32 @@
export interface HookRequest {
Type: 'post-finish' | 'pre-create';
Event: {
Upload: {
ID?: string;
MetaData?: {
filename?: string
};
Size?: number;
Storage?: {
Path: string
Type: string
};
};
};
}
export interface HookResponse {
RejectUpload?: boolean;
HTTPResponse?: {
StatusCode?: number;
Body?: string;
Headers?: Record<string, string>;
};
ChangeFileInfo?: {
ID?: string;
MetaData?: Record<string, string>;
};
}
export interface FileHandle {
filename: string
path: string
}

View File

@ -0,0 +1,20 @@
import { Controller, Logger, Post, Body, Res } from "@nestjs/common";
import { Response } from "express";
import { HookRequest, HookResponse } from "./types";
import { UploadService } from "./upload.service";
@Controller("upload")
export class UploadController {
private readonly logger = new Logger(UploadController.name);
constructor(private readonly uploadService: UploadService) { }
@Post("hook")
async handleTusHook(@Body() hookRequest: HookRequest, @Res() res: Response) {
try {
const hookResponse = await this.uploadService.handleTusHook(hookRequest);
return res.status(200).json(hookResponse);
} catch (error: any) {
this.logger.error(`Error handling hook: ${error.message}`);
return res.status(500).json({ error: 'Internal server error' });
}
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.registerQueue({
name: 'file-queue', // 确保这个名称与 service 中注入的队列名称一致
}),
],
controllers: [UploadController],
providers: [UploadService],
})
export class UploadModule { }

View File

@ -0,0 +1,107 @@
import { Injectable, Logger } from '@nestjs/common';
import { Queue, Job } from 'bullmq'; // 添加 Job 导入
import { InjectQueue } from '@nestjs/bullmq';
import { db, Resource, ResourceType } from '@nicestack/common';
import { HookRequest, HookResponse } from './types';
import dayjs from 'dayjs';
import { getFilenameWithoutExt } from '@server/utils/file';
import { QueueJobType } from '@server/queue/types';
import { nanoid } from 'nanoid';
import { slugify } from 'transliteration';
import path from 'path';
import fs from 'node:fs';
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
constructor(
@InjectQueue('file-queue') private fileQueue: Queue
) { }
async handlePreCreateHook(hookRequest: HookRequest): Promise<HookResponse> {
const hookResponse: HookResponse = {
HTTPResponse: {
Headers: {}
}
};
const metaData = hookRequest.Event.Upload.MetaData;
const isValid = metaData && 'filename' in metaData;
if (!isValid) {
hookResponse.RejectUpload = true;
hookResponse.HTTPResponse.StatusCode = 400;
hookResponse.HTTPResponse.Body = 'no filename provided';
hookResponse.HTTPResponse.Headers['X-Some-Header'] = 'yes';
} else {
const timestamp = dayjs().format('YYMMDDHHmmss');
const originalName = metaData.filename;
const extension = path.extname(originalName); // 获取文件扩展名
// 清理并转换文件名(不包含扩展名)
const cleanName = slugify(getFilenameWithoutExt(originalName), {
lowercase: true,
separator: '-',
trim: true
})
.replace(/[^\w-]/g, '')
.replace(/-+/g, '-')
.substring(0, 32);
const uniqueId = nanoid(6);
const fileId = `${timestamp}_${cleanName}_${uniqueId}`;
// await fs.promises.mkdir(path.join(process.env.UPLOAD_DIR, fileId), { recursive: true });
hookResponse.ChangeFileInfo = {
ID: `${fileId}/${fileId}${extension}`,
MetaData: {
filename: originalName,
normalized_name: cleanName,
folder: fileId
}
};
}
return hookResponse;
}
async handlePostFinishHook(hookRequest: HookRequest) {
const { ID, Size, Storage, MetaData } = hookRequest.Event.Upload;
const filename = MetaData?.filename;
const resource = await db.resource.create({
data: {
filename,
fileId: ID,
title: getFilenameWithoutExt(filename), // 使用没有扩展名的标题
metadata: MetaData || {}
}
})
await this.addToProcessorPipe(resource)
this.logger.log(`Upload ${ID} (${Size} bytes) is finished.`);
}
async handleTusHook(hookRequest: HookRequest): Promise<HookResponse> {
const hookResponse: HookResponse = {
HTTPResponse: {
Headers: {}
}
};
try {
if (hookRequest.Type === 'pre-create') {
return this.handlePreCreateHook(hookRequest);
}
if (hookRequest.Type === 'post-finish') {
this.handlePostFinishHook(hookRequest);
}
return hookResponse;
} catch (error: any) {
this.logger.error(`Error handling hook: ${error.message}`);
throw error;
}
}
async addToProcessorPipe(resource: Resource): Promise<Job> { // 修改返回类型为 Job
const job = await this.fileQueue.add(QueueJobType.FILE_PROCESS, {
resource,
timestamp: Date.now()
}, {
attempts: 3,
removeOnComplete: true,
jobId: resource.id
});
return job;
}
}

View File

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

View File

@ -1,14 +0,0 @@
#!/bin/sh
# 使用envsubst替换index.html中的环境变量占位符
envsubst < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
# 运行serve来提供静态文件
exec nginx -g "daemon off;"
# 使用 sed 替换 index.html 中的环境变量占位符
# for var in $(env | cut -d= -f1); do
# sed -i "s|\${$var}|$(eval echo \$$var)|g" /usr/share/nginx/html/index.html
# done
# # 运行 nginx 来提供静态文件
# exec nginx -g "daemon off;"

View File

@ -12,7 +12,7 @@
VITE_APP_VERSION: "$VITE_APP_VERSION", VITE_APP_VERSION: "$VITE_APP_VERSION",
}; };
</script> </script>
<title>烽火慕课</title> <title>烽火mooc</title>
</head> </head>
<body> <body>

View File

@ -42,6 +42,8 @@
"antd": "^5.19.3", "antd": "^5.19.3",
"axios": "^1.7.2", "axios": "^1.7.2",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.12", "dayjs": "^1.11.12",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
@ -54,6 +56,7 @@
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^6.24.1", "react-router-dom": "^6.24.1",
"superjson": "^2.2.1", "superjson": "^2.2.1",
"tailwind-merge": "^2.6.0",
"yjs": "^13.6.20", "yjs": "^13.6.20",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@ -1,5 +1,8 @@
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor"; import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
import { useParams } from "react-router-dom";
export function CourseEditorPage() { export function CourseEditorPage() {
return <CourseEditor></CourseEditor> const { id } = useParams();
console.log('Course ID:', id);
return <CourseEditor id={id} ></CourseEditor>
} }

View File

@ -1,6 +1,6 @@
import { CourseCard } from "@web/src/components/models/course/course-card" import { CourseCard } from "@web/src/components/models/course/card/CourseCard"
import { CourseDetail } from "@web/src/components/models/course/course-detail" import { CourseDetail } from "@web/src/components/models/course/detail/course-detail"
import { CourseSyllabus } from "@web/src/components/models/course/course-syllabus" import { CourseSyllabus } from "@web/src/components/models/course/detail/course-syllabus"
export const CoursePage = () => { export const CoursePage = () => {
// 假设这些数据从API获取 // 假设这些数据从API获取

View File

@ -0,0 +1,55 @@
import { PlusIcon } from "@heroicons/react/24/outline";
import { CourseList } from "@web/src/components/models/course/list/course-list";
import { Button } from "@web/src/components/presentation/element/Button";
import { api } from "@nicestack/client";
import { useState } from "react";
import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@web/src/providers/auth-provider";
export default function InstructorCoursesPage() {
const navigate = useNavigate()
const [currentPage, setCurrentPage] = useState(1);
const { user } = useAuth()
const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({
page: currentPage,
pageSize: 8,
where: {
instructors: {
some: {
instructorId: user?.id
}
}
}
});
const handlePageChange = (page: number) => {
setCurrentPage(page);
refetch()
};
return (
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-8 flex items-center justify-between">
<h1 className="text-3xl font-semibold text-slate-800"></h1>
<Button
onClick={() => navigate("/course/manage")}
variant="primary"
leftIcon={<PlusIcon className="w-5 h-5" />}
>
</Button>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => <CourseCard course={course} />}
/>
</div>
</div>
);
}

View File

@ -1,198 +0,0 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { CourseDto } from "@nicestack/common";
import { EmptyStateIllustration } from "@web/src/components/presentation/EmptyStateIllustration";
import { useNavigate } from "react-router-dom";
interface CourseCardProps {
course: CourseDto;
type: "created" | "enrolled";
}
const CourseCard = ({ course, type }: CourseCardProps) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ scale: 1.02 }}
className="group relative overflow-hidden rounded-2xl bg-white p-6 shadow-sm transition-all hover:shadow-md"
>
{/* Course Thumbnail */}
<div className="relative mb-4 aspect-video w-full overflow-hidden rounded-xl">
<motion.img
src={course.thumbnail || "/default-course-thumb.jpg"}
alt={course.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-2 left-2">
<span className="rounded-full bg-white/30 px-3 py-1 text-sm text-white backdrop-blur-sm">
{course.level}
</span>
</div>
</div>
{/* Course Info */}
<div className="space-y-2">
<h3 className="text-xl font-medium text-gray-700">
{course.title}
</h3>
{course.subTitle && (
<p className="text-sm text-gray-500">
{course.subTitle}
</p>
)}
</div>
{/* Course Stats */}
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center space-x-2 text-gray-500">
<span>{course.totalLectures} lectures</span>
<span></span>
<span>{course.totalDuration} mins</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-amber-400"></span>
<span className="text-gray-600">
{course.averageRating.toFixed(1)}
</span>
</div>
</div>
{/* Progress Bar (Only for enrolled courses) */}
{type === "enrolled" && course.enrollments[0] && (
<div className="mt-4">
<div className="relative h-2 w-full overflow-hidden rounded-full bg-gray-100">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${course.enrollments[0].completionRate}%` }}
className="absolute left-0 top-0 h-full rounded-full bg-indigo-400"
transition={{ duration: 1, ease: "easeOut" }}
/>
</div>
<p className="mt-1 text-right text-xs text-gray-400">
{course.enrollments[0].completionRate}% Complete
</p>
</div>
)}
</motion.div>
);
};
export default function CoursesPage() {
const [activeTab, setActiveTab] = useState<"enrolled" | "created">("enrolled");
const [courses, setCourses] = useState<CourseDto[]>([]);
const navigate = useNavigate()
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05,
duration: 0.3
},
},
};
return (
<div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold text-slate-800">
</h1>
<div className="mt-4">
<nav className="flex space-x-4">
{["enrolled", "created"].map((tab) => (
<motion.button
key={tab}
onClick={() => setActiveTab(tab as "enrolled" | "created")}
className={`relative rounded-lg px-6 py-2.5 text-sm font-medium ${activeTab === tab
? "bg-blue-500 text-white shadow-sm shadow-sky-100"
: "bg-white text-slate-600 hover:bg-white hover:text-blue-500 hover:shadow-sm"
}`}
whileHover={{ y: -2 }}
whileTap={{ y: 0 }}
transition={{ duration: 0.2 }}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)} Courses
</motion.button>
))}
</nav>
</div>
</div>
{activeTab === "created" && (
<motion.button
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
whileHover={{ y: -2 }}
whileTap={{ y: 0 }}
onClick={() => {
navigate("/course/manage")
}}
transition={{ duration: 0.2 }}
className="flex items-center gap-2 rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-200 hover:bg-blue-600 hover:shadow-md"
>
<span className="relative h-5 w-5">
<motion.svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={2}
stroke="currentColor"
className="h-5 w-5"
whileHover={{ rotate: 90 }}
transition={{ duration: 0.2 }}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</motion.svg>
</span>
</motion.button>
)}
</div>
{/* Course Grid */}
<motion.div
variants={container}
initial="hidden"
animate="show"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{courses.map((course) => (
<CourseCard
key={course.id}
course={course}
type={activeTab}
/>
))}
</motion.div>
{/* Empty State */}
{courses.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="flex flex-col items-center justify-center rounded-xl bg-white p-8 text-center shadow-sm"
>
<EmptyStateIllustration />
<h3 className="mb-2 text-xl font-medium text-slate-800">
No courses found
</h3>
<p className="text-slate-500">
{activeTab === "enrolled"
? "You haven't enrolled in any courses yet."
: "You haven't created any courses yet."}
</p>
</motion.div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { CourseList } from "@web/src/components/models/course/list/course-list";
import { api } from "@nicestack/client";
import { useState } from "react";
import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
import { useAuth } from "@web/src/providers/auth-provider";
export default function StudentCoursesPage() {
const [currentPage, setCurrentPage] = useState(1);
const { user } = useAuth()
const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({
page: currentPage,
pageSize: 8,
where: {
enrollments: {
some: {
studentId: user?.id
}
}
}
});
const handlePageChange = (page: number) => {
setCurrentPage(page);
refetch()
};
return (
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className="mb-8">
<h1 className="text-3xl font-semibold text-slate-800 mb-4"></h1>
</div>
<CourseList
totalPages={paginationRes?.totalPages}
onPageChange={handlePageChange}
currentPage={currentPage}
courses={paginationRes?.items as any}
renderItem={(course) => <CourseCard course={course}></CourseCard>}>
</CourseList>
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
import React, { useCallback, useState } from 'react';
import * as tus from 'tus-js-client';
const UploadTest: React.FC = () => {
const [progress, setProgress] = useState<number>(0);
const [uploadStatus, setUploadStatus] = useState<string>('');
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// 创建 tus upload 实例
const upload = new tus.Upload(file, {
endpoint: 'http://localhost:8080/files', // 替换成你的 NestJS 服务器地址
retryDelays: [0, 3000, 5000],
metadata: {
filename: file.name,
filetype: file.type
},
onError: (error) => {
console.error('上传失败:', error);
setUploadStatus('上传失败');
},
onProgress: (bytesUploaded, bytesTotal) => {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
setProgress(Number(percentage));
setUploadStatus('上传中...');
},
onSuccess: () => {
setUploadStatus('上传成功!');
console.log('上传完成:', upload.url);
}
});
// 开始上传
upload.start();
}, []);
return (
<div style={{ padding: '20px' }}>
<h2></h2>
<input
type="file"
onChange={handleFileSelect}
style={{ marginBottom: '20px' }}
/>
{progress > 0 && (
<div>
<div>: {progress}%</div>
<div style={{
width: '300px',
height: '20px',
border: '1px solid #ccc',
marginTop: '10px'
}}>
<div style={{
width: `${progress}%`,
height: '100%',
backgroundColor: '#4CAF50',
transition: 'width 0.3s'
}} />
</div>
</div>
)}
{uploadStatus && (
<div style={{ marginTop: '10px' }}>
: {uploadStatus}
</div>
)}
</div>
);
};
export default function HomePage() {
return <UploadTest></UploadTest>
}

View File

@ -1,145 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button, message } from "antd";
import { useMemo, useRef, useState } from "react";
import { Buffer } from "buffer";
import { useTransform } from "@nicestack/client";
import { SizeType } from "antd/es/config-provider/SizeContext";
import { useAuth } from "@web/src/providers/auth-provider";
import { api } from "@nicestack/client"
export function ExcelImporter({
type = "trouble",
className,
name = "导入",
taxonomyId,
parentId,
size = "small",
domainId,
refresh = true,
disabled = false,
ghost = true,
}: {
disabled?: boolean;
type?: "trouble" | "term" | "dept" | "staff";
className?: string;
name?: string;
domainId?: string;
taxonomyId?: string;
parentId?: string;
size?: SizeType;
refresh?: boolean;
ghost?: boolean;
}) {
const fileInput = useRef<HTMLInputElement>(null);
const [file, setFile] = useState();
const [loading, setLoading] = useState(false);
const { user } = useAuth();
const { importTrouble, importTerms, importDepts, importStaffs } =
useTransform();
const utils = api.useUtils()
// const queryKey = getQueryKey(api.trouble);
// const domainId = useMemo(() => {
// if (staff && staff?.domainId) return staff?.domainId;
// }, [staff]);
return (
<div className={className}>
<Button
size={size}
ghost={ghost}
type="primary"
loading={loading}
disabled={loading || disabled}
onClick={() => {
fileInput.current?.click();
}}>
{name}
</Button>
<input
ref={fileInput}
accept="application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
type="file"
onChange={async (e) => {
const files = Array.from(e.target.files || []);
if (!files.length) return; // 如果没有文件被选中, 直接返回
const file = files[0];
if (file) {
const isExcel =
file.type === "application/vnd.ms-excel" ||
file.type ===
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ||
file.type === "application/wps-office.xlsx";
if (!isExcel) {
message.warning("请选择Excel文件");
return;
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const bufferToBase64 = buffer.toString("base64");
try {
setLoading(true);
let data = undefined;
if (type === "trouble") {
data = await importTrouble.mutateAsync({
base64: bufferToBase64,
domainId,
});
}
if (type === "term") {
data = await importTerms.mutateAsync({
base64: bufferToBase64,
domainId,
taxonomyId,
parentId,
});
}
if (type === "dept") {
data = await importDepts.mutateAsync({
base64: bufferToBase64,
domainId,
parentId,
});
}
if (type === "staff") {
data = await importStaffs.mutateAsync({
base64: bufferToBase64,
domainId,
});
}
// const data = res.data;
console.log(`%cdata:${data}`, "color:red");
if (!data?.error) {
message.success(`已经导入${data.count}条数据`);
utils.trouble.invalidate()
if (refresh && type !== "trouble") {
setTimeout(() => {
window.location.reload();
}, 700);
}
} else {
console.log(
`%cerror:${JSON.stringify(data.error)}`,
"color:red"
);
console.log(JSON.stringify(data.error));
message.error(JSON.stringify(data.error));
}
} catch (error) {
console.error(`${error}`);
message.error(`${error}`);
} finally {
if (fileInput.current) {
fileInput.current.value = ""; // 清空文件输入
}
setLoading(false);
}
}
}}
style={{ display: "none" }}
/>
</div>
);
}

View File

@ -3,9 +3,10 @@ import { motion, AnimatePresence } from 'framer-motion';
import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar'; import { TopNavBar } from '@web/src/components/layout/main/top-nav-bar';
import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data'; import { navItems, notificationItems } from '@web/src/components/layout/main/nav-data';
import { Sidebar } from '@web/src/components/layout/main/side-bar'; import { Sidebar } from '@web/src/components/layout/main/side-bar';
import { Outlet } from 'react-router-dom';
export function MainLayout({ children }: { children: ReactNode }) { export function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [notifications, setNotifications] = useState(3); const [notifications, setNotifications] = useState(3);
const [recentSearches] = useState([ const [recentSearches] = useState([
@ -32,7 +33,7 @@ export function MainLayout({ children }: { children: ReactNode }) {
className={`pt-16 min-h-screen transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0' className={`pt-16 min-h-screen transition-all duration-300 ${sidebarOpen ? 'ml-64' : 'ml-0'
}`} }`}
> >
{children} <Outlet></Outlet>
</main> </main>
</div> </div>
); );

View File

@ -6,16 +6,18 @@ import {
Cog6ToothIcon, Cog6ToothIcon,
BellIcon, BellIcon,
HeartIcon, HeartIcon,
AcademicCapIcon AcademicCapIcon,
UsersIcon,
PresentationChartBarIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export const navItems: NavItem[] = [ export const navItems: NavItem[] = [
{ icon: <HomeIcon className="w-6 h-6" />, label: '探索', path: '/' }, { icon: <HomeIcon className="w-6 h-6" />, label: '探索知识', path: '/' },
{ icon: <BookOpenIcon className="w-6 h-6" />, label: '我的课程', path: '/courses' }, { icon: <AcademicCapIcon className="w-6 h-6" />, label: '我的学习', path: '/courses/student' },
{ icon: <UserGroupIcon className="w-6 h-6" />, label: '学习社区', path: '/community' }, { icon: <PresentationChartBarIcon className="w-6 h-6" />, label: '我的授课', path: '/courses/instructor' },
{ icon: <UsersIcon className="w-6 h-6" />, label: '学习社区', path: '/community' },
{ icon: <Cog6ToothIcon className="w-6 h-6" />, label: '应用设置', path: '/settings' }, { icon: <Cog6ToothIcon className="w-6 h-6" />, label: '应用设置', path: '/settings' },
]; ];
export const notificationItems = [ export const notificationItems = [
{ {
icon: <BellIcon className="w-6 h-6 text-blue-500" />, icon: <BellIcon className="w-6 h-6 text-blue-500" />,

View File

@ -1,4 +1,3 @@
import { useState, useRef } from 'react';
import { NotificationsDropdown } from './notifications-dropdown'; import { NotificationsDropdown } from './notifications-dropdown';
import { SearchBar } from './search-bar'; import { SearchBar } from './search-bar';
@ -12,7 +11,6 @@ interface TopNavBarProps {
notificationItems: Array<any>; notificationItems: Array<any>;
recentSearches: string[]; recentSearches: string[];
} }
export function TopNavBar({ export function TopNavBar({
sidebarOpen, sidebarOpen,
setSidebarOpen, setSidebarOpen,
@ -26,12 +24,11 @@ export function TopNavBar({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
onClick={() => setSidebarOpen(!sidebarOpen)} onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 transition-colors">
> <Bars3Icon className='w-5 h-5' />
<Bars3Icon />
</button> </button>
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"> <h1 className="text-xl font-semibold text-slate-800 tracking-wide">
LearnHub mooc
</h1> </h1>
</div> </div>

View File

@ -29,11 +29,11 @@ export const CourseHeader = ({
</div> </div>
)} )}
<div className="p-6"> <div className="p-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">{title}</h3> <h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
{subTitle && ( {subTitle && (
<p className="mt-2 text-gray-600 dark:text-gray-300">{subTitle}</p> <p className="mt-2 text-gray-600 ">{subTitle}</p>
)} )}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400"> <div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
{level && ( {level && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<AcademicCapIcon className="h-4 w-4" /> <AcademicCapIcon className="h-4 w-4" />

View File

@ -14,15 +14,15 @@ export const CourseStats = ({
totalDuration, totalDuration,
}: CourseStatsProps) => { }: CourseStatsProps) => {
return ( return (
<div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 dark:bg-gray-900"> <div className="grid grid-cols-2 gap-4 p-6 bg-gray-50 ">
{averageRating !== undefined && ( {averageRating !== undefined && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StarIcon className="h-5 w-5 text-yellow-400" /> <StarIcon className="h-5 w-5 text-yellow-400" />
<div> <div>
<div className="font-semibold text-gray-900 dark:text-white"> <div className="font-semibold text-gray-900 ">
{averageRating.toFixed(1)} {averageRating.toFixed(1)}
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 ">
{numberOfReviews} reviews {numberOfReviews} reviews
</div> </div>
</div> </div>
@ -32,10 +32,10 @@ export const CourseStats = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ChartBarIcon className="h-5 w-5 text-green-500" /> <ChartBarIcon className="h-5 w-5 text-green-500" />
<div> <div>
<div className="font-semibold text-gray-900 dark:text-white"> <div className="font-semibold text-gray-900 ">
{completionRate}% {completionRate}%
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 ">
Completion Completion
</div> </div>
</div> </div>
@ -45,10 +45,10 @@ export const CourseStats = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ClockIcon className="h-5 w-5 text-blue-500" /> <ClockIcon className="h-5 w-5 text-blue-500" />
<div> <div>
<div className="font-semibold text-gray-900 dark:text-white"> <div className="font-semibold text-gray-900 ">
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m {Math.floor(totalDuration / 60)}h {totalDuration % 60}m
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 ">
Duration Duration
</div> </div>
</div> </div>

View File

@ -2,10 +2,18 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Course, CourseDto } from "@nicestack/common"; import { Course, CourseDto } from "@nicestack/common";
import { EmptyState } from "@web/src/components/presentation/space/Empty"; import { EmptyState } from "@web/src/components/presentation/space/Empty";
import { Pagination } from "@web/src/components/presentation/element/Pagination";
interface CourseListProps { interface CourseListProps {
courses: CourseDto[]; courses?: CourseDto[];
activeTab: "enrolled" | "created"; renderItem: (course: CourseDto) => React.ReactNode;
emptyComponent?: React.ReactNode;
// 新增分页相关属性
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
} }
const container = { const container = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { show: {
@ -20,23 +28,35 @@ export const CourseList = ({
courses, courses,
renderItem, renderItem,
emptyComponent: EmptyComponent, emptyComponent: EmptyComponent,
}: CourseListProps & { currentPage,
renderItem?: (course: CourseDto) => React.ReactNode; totalPages,
emptyComponent?: React.ReactNode; onPageChange,
}) => { }: CourseListProps) => {
if (courses.length === 0) { if (!courses || courses.length === 0) {
return EmptyComponent || ( return EmptyComponent || <EmptyState />;
<EmptyState />
);
} }
return ( return (
<div>
<motion.div <motion.div
variants={container} variants={container}
initial="hidden" initial="hidden"
animate="show" animate="show"
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
> >
{courses.map((course) => renderItem(course))} {courses.map((course) => (
<motion.div key={course.id}>
{renderItem(course)}
</motion.div> </motion.div>
))}
</motion.div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
); );
}; };

View File

@ -1,10 +1,11 @@
import { SubmitHandler, useFormContext } from 'react-hook-form'; import { SubmitHandler, useFormContext } from 'react-hook-form';
import { CourseFormData, useCourseForm } from './CourseEditorContext'; import { CourseFormData, useCourseEditor } from './CourseEditorContext';
import { CourseLevel } from '@nicestack/common'; import { CourseLevel, CourseLevelLabel } from '@nicestack/common';
import { FormInput } from '@web/src/components/presentation/form/FormInput'; import { FormInput } from '@web/src/components/presentation/form/FormInput';
import { FormSelect } from '@web/src/components/presentation/form/FormSelect'; import { FormSelect } from '@web/src/components/presentation/form/FormSelect';
import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField'; import { FormArrayField } from '@web/src/components/presentation/form/FormArrayField';
import { convertToOptions } from '@nicestack/client';
export function CourseBasicForm() { export function CourseBasicForm() {
const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>(); const { register, formState: { errors }, watch, handleSubmit } = useFormContext<CourseFormData>();
@ -19,15 +20,7 @@ export function CourseBasicForm() {
type="textarea" type="textarea"
placeholder="请输入课程描述" placeholder="请输入课程描述"
/> />
<FormSelect name='level' label='难度等级' options={[ <FormSelect name='level' label='难度等级' options={convertToOptions(CourseLevelLabel)}></FormSelect>
{ label: '入门', value: CourseLevel.BEGINNER },
{
label: '中级', value: CourseLevel.INTERMEDIATE
},
{
label: '高级', value: CourseLevel.ADVANCED
}
]}></FormSelect>
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */} {/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
</form> </form>
); );

View File

@ -3,8 +3,8 @@ import { CourseBasicForm } from "./CourseBasicForm";
import { CourseFormProvider } from "./CourseEditorContext"; import { CourseFormProvider } from "./CourseEditorContext";
import CourseEditorLayout from "./CourseEditorLayout"; import CourseEditorLayout from "./CourseEditorLayout";
export default function CourseEditor() { export default function CourseEditor({ id }: { id?: string }) {
return <CourseFormProvider> return <CourseFormProvider editId={id}>
<CourseEditorLayout> <CourseEditorLayout>
<CourseBasicForm></CourseBasicForm> <CourseBasicForm></CourseBasicForm>
</CourseEditorLayout> </CourseEditorLayout>

View File

@ -1,60 +1,97 @@
import { createContext, useContext, ReactNode } from 'react'; import { createContext, useContext, ReactNode, useEffect } from 'react';
import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; import { useForm, FormProvider, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { CourseLevel, CourseStatus } from '@nicestack/common'; import { CourseDto, CourseLevel, CourseStatus } from '@nicestack/common';
import { useCourse } from '@nicestack/client'; import { api, useCourse } from '@nicestack/client';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
// 定义课程表单验证 Schema // 定义课程表单验证 Schema
const courseSchema = z.object({ const courseSchema = z.object({
title: z.string().min(1, '课程标题不能为空'), title: z.string().min(1, '课程标题不能为空'),
subTitle: z.string().optional(), subTitle: z.string().nullish(),
description: z.string().optional(), description: z.string().nullish(),
thumbnail: z.string().url().optional(), thumbnail: z.string().url().nullish(),
level: z.nativeEnum(CourseLevel), level: z.nativeEnum(CourseLevel),
requirements: z.array(z.string()).optional(), requirements: z.array(z.string()).nullish(),
objectives: z.array(z.string()).optional(), objectives: z.array(z.string()).nullish(),
skills: z.array(z.string()).optional(), skills: z.array(z.string()).nullish(),
audiences: z.array(z.string()).optional(), audiences: z.array(z.string()).nullish(),
status: z.nativeEnum(CourseStatus), status: z.nativeEnum(CourseStatus),
}); });
export type CourseFormData = z.infer<typeof courseSchema>; export type CourseFormData = z.infer<typeof courseSchema>;
interface CourseEditorContextType { interface CourseEditorContextType {
onSubmit: SubmitHandler<CourseFormData>; onSubmit: SubmitHandler<CourseFormData>;
editId?: string; // 添加 editId
course?: CourseDto
}
interface CourseFormProviderProps {
children: ReactNode;
editId?: string; // 添加 editId 参数
} }
const CourseEditorContext = createContext<CourseEditorContextType | null>(null); const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
export function CourseFormProvider({ children }: { children: ReactNode }) { export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
const { create } = useCourse() const { create, update } = useCourse()
const { data: course }: { data: CourseDto } = api.course.findFirst.useQuery({ where: { id: editId } }, { enabled: Boolean(editId) })
const methods = useForm<CourseFormData>({ const methods = useForm<CourseFormData>({
resolver: zodResolver(courseSchema), resolver: zodResolver(courseSchema),
defaultValues: { defaultValues: {
status: CourseStatus.DRAFT, status: CourseStatus.DRAFT,
level: CourseLevel.BEGINNER, level: CourseLevel.BEGINNER,
requirements: [], requirements: [],
objectives: [], objectives: [],
skills: [], skills: [],
audiences: [], audiences: [],
}, },
}); });
const navigate = useNavigate()
useEffect(() => {
if (course) {
// 只选择表单需要的字段
const formData = {
title: course.title,
subTitle: course.subTitle,
description: course.description,
thumbnail: course.thumbnail,
level: course.level,
requirements: course.requirements,
objectives: course.objectives,
skills: course.skills,
audiences: course.audiences,
status: course.status,
};
methods.reset(formData as any);
}
}, [course, methods]);
const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => { const onSubmit: SubmitHandler<CourseFormData> = async (data: CourseFormData) => {
try { try {
// TODO: 实现API调用 if (editId) {
console.log('Form data:', data); await update.mutateAsync({
await create.mutateAsync({ where: { id: editId },
data: { data: {
...data ...data
} }
}) })
toast.success('课程更新成功!');
} else {
const result = await create.mutateAsync({
data: {
...data
}
})
console.log(`/course/${result.id}/manage`)
navigate(`/course/${result.id}/manage`, { replace: true })
toast.success('课程创建成功!');
}
} catch (error) { } catch (error) {
console.error('Error submitting form:', error); console.error('Error submitting form:', error);
toast.error('操作失败,请重试!');
} }
}; };
return ( return (
<CourseEditorContext.Provider value={{ onSubmit }}> <CourseEditorContext.Provider value={{ onSubmit, editId, course }}>
<FormProvider {...methods}> <FormProvider {...methods}>
{children} {children}
</FormProvider> </FormProvider>
@ -62,10 +99,10 @@ export function CourseFormProvider({ children }: { children: ReactNode }) {
); );
} }
export const useCourseForm = () => { export const useCourseEditor = () => {
const context = useContext(CourseEditorContext); const context = useContext(CourseEditorContext);
if (!context) { if (!context) {
throw new Error('useCourseForm must be used within CourseFormProvider'); throw new Error('useCourseEditor must be used within CourseFormProvider');
} }
return context; return context;
}; };

View File

@ -1,12 +1,20 @@
import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline'; import { ArrowLeftIcon, ClockIcon } from '@heroicons/react/24/outline';
import { SubmitHandler, useFormContext } from 'react-hook-form'; import { SubmitHandler, useFormContext } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { CourseFormData, useCourseForm } from './CourseEditorContext'; import { CourseFormData, useCourseEditor } from './CourseEditorContext';
import { Button } from '@web/src/components/presentation/element/Button';
import { CourseStatus, CourseStatusLabel } from '@nicestack/common';
import Tag from '@web/src/components/presentation/element/Tag';
const courseStatusVariant: Record<CourseStatus, string> = {
[CourseStatus.DRAFT]: 'default',
[CourseStatus.UNDER_REVIEW]: 'warning',
[CourseStatus.PUBLISHED]: 'success',
[CourseStatus.ARCHIVED]: 'danger'
};
export default function CourseEditorHeader() { export default function CourseEditorHeader() {
const navigate = useNavigate(); const navigate = useNavigate();
const { handleSubmit} = useFormContext<CourseFormData>() const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
const { onSubmit } = useCourseForm() const { onSubmit, course } = useCourseEditor()
return ( return (
<header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10"> <header className="fixed top-0 left-0 right-0 h-16 bg-white border-b border-gray-200 z-10">
<div className="h-full flex items-center justify-between px-3 md:px-4"> <div className="h-full flex items-center justify-between px-3 md:px-4">
@ -18,17 +26,25 @@ export default function CourseEditorHeader() {
<ArrowLeftIcon className="w-5 h-5 text-gray-600" /> <ArrowLeftIcon className="w-5 h-5 text-gray-600" />
</button> </button>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<h2 className="font-medium text-gray-900">UI设计入门课程</h2> <h2 className="font-medium text-gray-900">{course?.title || '新建课程'}</h2>
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-600"></span> <Tag variant={courseStatusVariant[course?.status || CourseStatus.DRAFT]}>
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
</Tag>
{course?.totalDuration ? (
<div className="hidden md:flex items-center text-gray-500 text-sm"> <div className="hidden md:flex items-center text-gray-500 text-sm">
<ClockIcon className="w-4 h-4 mr-1" /> <ClockIcon className="w-4 h-4 mr-1" />
<span> 12:30:00</span> <span> {course?.totalDuration}</span>
</div>
) : null}
</div> </div>
</div> </div>
</div> <Button
<button onClick={handleSubmit(onSubmit)} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"> disabled={course ? (!isValid || !isDirty) : !isValid}
size="sm"
onClick={handleSubmit(onSubmit)}
>
</button> </Button>
</div> </div>
</header> </header>
); );

View File

@ -1,5 +1,5 @@
import { TreeSelect, TreeSelectProps } from "antd"; import { TreeSelect, TreeSelectProps } from "antd";
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback, useRef } from "react";
import { getUniqueItems } from "@nicestack/common"; import { getUniqueItems } from "@nicestack/common";
import { api } from "@nicestack/client"; import { api } from "@nicestack/client";
import { DefaultOptionType } from "antd/es/select"; import { DefaultOptionType } from "antd/es/select";
@ -15,6 +15,7 @@ interface TermSelectProps {
taxonomyId?: string; taxonomyId?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
domainId?: string;
} }
export default function TermSelect({ export default function TermSelect({
@ -25,6 +26,7 @@ export default function TermSelect({
placeholder = "选择单位", placeholder = "选择单位",
multiple = false, multiple = false,
taxonomyId, taxonomyId,
domainId,
// rootId = null, // rootId = null,
disabled = false, disabled = false,
// domain = undefined, // domain = undefined,
@ -43,6 +45,7 @@ export default function TermSelect({
return await utils.term.getParentSimpleTree.fetch({ return await utils.term.getParentSimpleTree.fetch({
termIds: idsArray, termIds: idsArray,
taxonomyId, taxonomyId,
domainId,
}); });
} catch (error) { } catch (error) {
console.error( console.error(
@ -61,6 +64,7 @@ export default function TermSelect({
try { try {
const rootDepts = await utils.term.getChildSimpleTree.fetch({ const rootDepts = await utils.term.getChildSimpleTree.fetch({
taxonomyId, taxonomyId,
domainId,
}); });
let combinedDepts = [...rootDepts]; let combinedDepts = [...rootDepts];
if (defaultValue) { if (defaultValue) {
@ -106,6 +110,7 @@ export default function TermSelect({
const result = await utils.term.getChildSimpleTree.fetch({ const result = await utils.term.getChildSimpleTree.fetch({
termIds: [id], termIds: [id],
taxonomyId, taxonomyId,
domainId,
}); });
const newItems = getUniqueItems([...listTreeData, ...result], "id"); const newItems = getUniqueItems([...listTreeData, ...result], "id");
setListTreeData(newItems); setListTreeData(newItems);
@ -137,6 +142,7 @@ export default function TermSelect({
const expandedNodes = await utils.term.getChildSimpleTree.fetch({ const expandedNodes = await utils.term.getChildSimpleTree.fetch({
termIds: allKeyIds, termIds: allKeyIds,
taxonomyId, taxonomyId,
domainId,
}); });
const flattenedNodes = expandedNodes.flat(); const flattenedNodes = expandedNodes.flat();
const newItems = getUniqueItems( const newItems = getUniqueItems(
@ -163,6 +169,12 @@ export default function TermSelect({
disabled={disabled} disabled={disabled}
showSearch showSearch
allowClear allowClear
// ref={selectRef}
dropdownStyle={{
width: "300px", // 固定宽度
minWidth: "200px", // 最小宽度
maxWidth: "600px", // 最大宽度
}}
defaultValue={defaultValue} defaultValue={defaultValue}
value={value} value={value}
className={className} className={className}

View File

@ -1,6 +1,11 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
export const EmptyStateIllustration = () => { export const EmptyStateIllustration = () => {
const springTransition = {
type: "spring",
stiffness: 100,
damping: 10
};
return ( return (
<motion.svg <motion.svg
width="240" width="240"
@ -8,68 +13,149 @@ export const EmptyStateIllustration = () => {
viewBox="0 0 240 200" viewBox="0 0 240 200"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
initial={{ opacity: 0, scale: 0.9 }} initial="hidden"
animate={{ opacity: 1, scale: 1 }} animate="visible"
transition={{ duration: 0.5, ease: "easeOut" }} variants={{
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: {
when: "beforeChildren",
staggerChildren: 0.1
}
}
}}
transition={springTransition}
> >
{/* Background Elements */}
<motion.path
d="M40 100C40 60 60 20 120 20C180 20 200 60 200 100C200 140 180 180 120 180C60 180 40 140 40 100Z"
fill="#F3F4F6"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
/>
{/* Books Stack */} {/* 书籍堆叠 - 使用更现代的配色方案 */}
<motion.g <motion.g
initial={{ y: 20, opacity: 0 }} variants={{
animate={{ y: 0, opacity: 1 }} hidden: { opacity: 0, y: 30 },
transition={{ duration: 0.5, delay: 0.4 }} visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 80,
damping: 12
}
}
}}
> >
{/* Bottom Book */} {/* 底部书籍 */}
<path <path
d="M90 120H150C152.761 120 155 117.761 155 115V105C155 102.239 152.761 100 150 100H90C87.2386 100 85 102.239 85 105V115C85 117.761 87.2386 120 90 120Z" d="M90 120H150C152.761 120 155 117.761 155 115V105C155 102.239 152.761 100 150 100H90C87.2386 100 85 102.239 85 105V115C85 117.761 87.2386 120 90 120Z"
fill="#E0E7FF" fill="#EFF6FF"
filter="url(#shadow)"
/> />
{/* Middle Book */} {/* 中间书籍 */}
<path <path
d="M95 100H155C157.761 100 160 97.7614 160 95V85C160 82.2386 157.761 80 155 80H95C92.2386 80 90 82.2386 90 85V95C90 97.7614 92.2386 100 95 100Z" d="M95 100H155C157.761 100 160 97.7614 160 95V85C160 82.2386 157.761 80 155 80H95C92.2386 80 90 82.2386 90 85V95C90 97.7614 92.2386 100 95 100Z"
fill="#818CF8" fill="#DBEAFE"
/> />
{/* Top Book */} {/* 顶部书籍 */}
<path <path
d="M100 80H160C162.761 80 165 77.7614 165 75V65C165 62.2386 162.761 60 160 60H100C97.2386 60 95 62.2386 95 65V75C95 77.7614 97.2386 80 100 80Z" d="M100 80H160C162.761 80 165 77.7614 165 75V65C165 62.2386 162.761 60 160 60H100C97.2386 60 95 62.2386 95 65V75C95 77.7614 97.2386 80 100 80Z"
fill="#6366F1" fill="#3B82F6"
/> />
</motion.g> </motion.g>
{/* Floating Elements */} {/* Magnifying Glass */}
<motion.g <motion.g
initial={{ scale: 0 }} variants={{
animate={{ scale: 1 }} hidden: { scale: 0, rotate: -45, x: 20 },
transition={{ duration: 0.5, delay: 0.6 }} visible: { scale: 1, rotate: 0, x: 0 }
}}
transition={{
type: "spring",
stiffness: 120,
damping: 15
}}
> >
{/* Small Circles */} <circle
<circle cx="70" cy="60" r="4" fill="#C7D2FE" /> cx="160"
<circle cx="180" cy="140" r="6" fill="#818CF8" /> cy="70"
<circle cx="160" cy="40" r="5" fill="#6366F1" /> r="15"
<circle cx="60" cy="140" r="5" fill="#E0E7FF" /> stroke="#3B82F6"
</motion.g> strokeWidth="2"
fill="white"
{/* Decorative Lines */} filter="url(#glow)"
<motion.g />
stroke="#C7D2FE" <line
x1="150"
y1="82"
x2="140"
y2="92"
stroke="#3B82F6"
strokeWidth="2" strokeWidth="2"
strokeLinecap="round" strokeLinecap="round"
initial={{ pathLength: 0 }} />
animate={{ pathLength: 1 }} </motion.g>
transition={{ duration: 1, delay: 0.8 }}
{/* Atom Structure */}
<motion.g
variants={{
hidden: { rotate: 0, opacity: 0, scale: 0.8 },
visible: {
rotate: 360,
opacity: 1,
scale: 1,
transition: {
rotate: {
duration: 25,
ease: "linear",
repeat: Infinity,
repeatType: "loop"
},
opacity: { duration: 0.5 },
scale: { duration: 0.5 }
}
}
}}
>
<ellipse cx="80" cy="100" rx="20" ry="10" stroke="#3B82F6" strokeWidth="1.5" fill="none" opacity="0.6" />
<ellipse cx="80" cy="100" rx="20" ry="10" stroke="#3B82F6" strokeWidth="2" fill="none" transform="rotate(60 80 100)" />
<ellipse cx="80" cy="100" rx="20" ry="10" stroke="#3B82F6" strokeWidth="2" fill="none" transform="rotate(-60 80 100)" />
<circle cx="80" cy="100" r="3" fill="#3B82F6" />
</motion.g>
{/* Mathematical Symbols */}
<motion.g
variants={{
hidden: { opacity: 0, y: 15 },
visible: { opacity: 1, y: 0 }
}}
fill="#3B82F6"
>
<text x="155" y="140" fontSize="16" fontFamily="serif" fontWeight="600"></text>
<text x="170" y="140" fontSize="16" fontFamily="serif" fontWeight="600"></text>
<text x="185" y="140" fontSize="16" fontFamily="serif" fontWeight="600">π</text>
</motion.g>
{/* Connection Lines */}
<motion.g
stroke="#BFDBFE"
strokeWidth="1.5"
strokeLinecap="round"
strokeDasharray="4,4"
variants={{
hidden: { opacity: 0, pathLength: 0 },
visible: { opacity: 0.6, pathLength: 1 }
}}
transition={{
duration: 2,
ease: "easeInOut"
}}
> >
<line x1="40" y1="80" x2="60" y2="80" /> <line x1="40" y1="80" x2="60" y2="80" />
<line x1="180" y1="120" x2="200" y2="120" /> <line x1="180" y1="120" x2="200" y2="120" />
<line x1="160" y1="160" x2="180" y2="160" /> <line x1="160" y1="160" x2="180" y2="160" />
</motion.g> </motion.g>
</motion.svg> </motion.svg>
); );
}; };

View File

@ -13,10 +13,10 @@ export const Card = ({ children, className = '', hover = true, onClick }: CardPr
<motion.div <motion.div
whileHover={hover ? { y: -5, transition: { duration: 0.2 } } : undefined} whileHover={hover ? { y: -5, transition: { duration: 0.2 } } : undefined}
className={` className={`
bg-white dark:bg-gray-800 bg-white
rounded-xl shadow-lg rounded-xl shadow-lg
overflow-hidden overflow-hidden
border border-gray-100 dark:border-gray-700 border border-gray-100
${hover ? 'cursor-pointer' : ''} ${hover ? 'cursor-pointer' : ''}
${className} ${className}
`} `}

View File

@ -0,0 +1,44 @@
import { motion } from "framer-motion";
interface TabOption {
id: string;
label: string;
}
interface AnimatedTabsProps {
options: TabOption[];
activeTab: string;
onChange: (tabId: string) => void;
className?: string;
}
export function AnimatedTabs({ options, activeTab, onChange, className = "" }: AnimatedTabsProps) {
return (
<nav className={`flex items-center gap-2 rounded-xl bg-slate-100/60 p-2 ${className}`}>
{options.map((option) => (
<motion.button
key={option.id}
onClick={() => onChange(option.id)}
className={`relative rounded-lg px-4 py-2 text-sm font-medium transition-colors
${activeTab === option.id
? "text-slate-900"
: "text-slate-600 hover:text-slate-900"
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
{activeTab === option.id && (
<motion.div
className="absolute inset-0 z-0 rounded-lg bg-white"
layoutId="activeTab"
style={{ boxShadow: "0 2px 8px rgba(0,0,0,0.1)" }}
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
/>
)}
<span className="relative z-10">{option.label}</span>
</motion.button>
))}
</nav>
);
}

View File

@ -0,0 +1,101 @@
import { HTMLMotionProps, motion } from 'framer-motion';
import { forwardRef, ReactNode } from 'react';
import { cn } from '@web/src/utils/classname';
import { LoadingOutlined } from '@ant-design/icons';
export interface ButtonProps extends Omit<HTMLMotionProps<"button">, keyof React.ButtonHTMLAttributes<HTMLButtonElement>> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
fullWidth?: boolean;
className?: string;
disabled?: boolean;
children?: ReactNode;
// 添加常用的按钮事件处理器
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onMouseEnter?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onMouseLeave?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (event: React.FocusEvent<HTMLButtonElement>) => void;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
isLoading = false,
disabled,
leftIcon,
rightIcon,
children,
fullWidth,
...props
},
ref
) => {
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm disabled:bg-gray-400',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 disabled:bg-gray-200 disabled:text-gray-500',
outline: 'border-2 border-gray-300 hover:bg-gray-50 disabled:border-gray-200 disabled:text-gray-400',
ghost: 'hover:bg-gray-100 disabled:bg-transparent disabled:text-gray-400',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:bg-gray-400',
};
const ringColors = {
primary: 'focus:ring-blue-500',
secondary: 'focus:ring-gray-500',
outline: 'focus:ring-gray-400',
ghost: 'focus:ring-gray-400',
danger: 'focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<motion.button
ref={ref}
disabled={disabled || isLoading}
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium',
'transition-all duration-200 ease-in-out',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-75 disabled:cursor-not-allowed disabled:hover:scale-100',
// Variant styles
variants[variant],
// Ring color based on variant
ringColors[variant],
// Size styles
sizes[size],
// Full width
fullWidth && 'w-full',
className
)}
whileTap={{ scale: 0.98 }}
whileHover={{ scale: 1.01 }}
{...props}
>
{isLoading && (
<LoadingOutlined className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
{!isLoading && leftIcon && (
<span className="mr-2 inline-flex">{leftIcon}</span>
)}
{children}
{!isLoading && rightIcon && (
<span className="ml-2 inline-flex">{rightIcon}</span>
)}
</motion.button>
);
}
);
Button.displayName = 'Button';

View File

@ -0,0 +1,106 @@
import { motion } from 'framer-motion';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
maxVisiblePages?: number;
className?: string;
}
export const Pagination = ({
currentPage,
totalPages,
onPageChange,
maxVisiblePages = 7,
className = '',
}: PaginationProps) => {
const getVisiblePages = () => {
if (totalPages <= maxVisiblePages) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const leftSiblingIndex = Math.max(currentPage - 1, 1);
const rightSiblingIndex = Math.min(currentPage + 1, totalPages);
const shouldShowLeftDots = leftSiblingIndex > 2;
const shouldShowRightDots = rightSiblingIndex < totalPages - 1;
if (!shouldShowLeftDots && shouldShowRightDots) {
const leftRange = Array.from({ length: maxVisiblePages - 1 }, (_, i) => i + 1);
return [...leftRange, '...', totalPages];
}
if (shouldShowLeftDots && !shouldShowRightDots) {
const rightRange = Array.from(
{ length: maxVisiblePages - 1 },
(_, i) => totalPages - (maxVisiblePages - 2) + i
);
return [1, '...', ...rightRange];
}
if (shouldShowLeftDots && shouldShowRightDots) {
const middleRange = Array.from(
{ length: maxVisiblePages - 4 },
(_, i) => leftSiblingIndex + i
);
return [1, '...', ...middleRange, '...', totalPages];
}
return Array.from({ length: totalPages }, (_, i) => i + 1);
};
const visiblePages = getVisiblePages();
return (
<motion.div
className={`flex items-center justify-center gap-2 mt-6 ${className}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="px-4 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50
disabled:opacity-50 disabled:cursor-not-allowed transition-colors
shadow-sm text-gray-700 font-medium"
>
</motion.button>
<div className="flex gap-2">
{visiblePages.map((page, index) => (
<motion.button
key={index}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => typeof page === 'number' && onPageChange(page)}
className={`px-4 py-2 rounded-md font-medium transition-colors ${currentPage === page
? 'bg-blue-500 text-white shadow-md'
: page === '...'
? 'cursor-default'
: 'bg-white border border-gray-200 hover:bg-gray-50 text-gray-700 shadow-sm'
}`}
>
{page}
</motion.button>
))}
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="px-4 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50
disabled:opacity-50 disabled:cursor-not-allowed transition-colors
shadow-sm text-gray-700 font-medium"
>
</motion.button>
</motion.div>
);
};

View File

@ -0,0 +1,88 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { motion } from 'framer-motion'
import { FC, ReactNode } from 'react'
const tagVariants = cva(
'inline-flex items-center justify-center rounded-full px-3 py-1 text-sm font-medium transition-all',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
primary: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
success: 'bg-green-100 text-green-800 hover:bg-green-200',
warning: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
danger: 'bg-red-100 text-red-800 hover:bg-red-200',
},
size: {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-3 py-1',
lg: 'text-base px-4 py-1.5',
},
interactive: {
true: 'cursor-pointer',
},
removable: {
true: 'pr-2',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
interactive: false,
removable: false,
},
}
)
interface TagProps extends VariantProps<typeof tagVariants> {
children: ReactNode
onClick?: () => void
onRemove?: () => void
className?: string
}
const Tag: FC<TagProps> = ({
children,
variant,
size,
interactive,
removable,
onClick,
onRemove,
className,
}) => {
return (
<motion.span
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
whileHover={interactive ? { scale: 1.05 } : undefined}
whileTap={interactive ? { scale: 0.95 } : undefined}
className={tagVariants({ variant, size, interactive, removable, className })}
onClick={onClick}
>
{children}
{removable && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => {
e.stopPropagation()
onRemove?.()
}}
className="ml-1.5 rounded-full p-0.5 hover:bg-black/5"
>
<svg
className="h-3 w-3 opacity-50"
viewBox="0 0 12 12"
fill="currentColor"
>
<path d="M6 4.586l2.293-2.293a1 1 0 0 1 1.414 1.414L7.414 6l2.293 2.293a1 1 0 0 1-1.414 1.414L6 7.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L4.586 6 2.293 3.707a1 1 0 0 1 1.414-1.414L6 4.586z" />
</svg>
</motion.button>
)}
</motion.span>
)
}
export default Tag

View File

@ -98,7 +98,11 @@ export function FormSelect({ name, label, options, placeholder = '请选择' }:
className={`p-2 cursor-pointer flex items-center justify-between className={`p-2 cursor-pointer flex items-center justify-between
${value === option.value ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`} ${value === option.value ? 'bg-blue-50 text-blue-600' : 'hover:bg-gray-50'}`}
onClick={() => { onClick={() => {
setValue(name, option.value); setValue(name, option.value, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
setIsOpen(false); setIsOpen(false);
}} }}
> >

View File

@ -13,11 +13,12 @@ import RoleAdminPage from "../app/admin/role/page";
import WithAuth from "../components/utils/with-auth"; import WithAuth from "../components/utils/with-auth";
import LoginPage from "../app/login"; import LoginPage from "../app/login";
import BaseSettingPage from "../app/admin/base-setting/page"; import BaseSettingPage from "../app/admin/base-setting/page";
import CoursesPage from "../app/main/courses/page";
import { CoursePage } from "../app/main/course/page"; import { CoursePage } from "../app/main/course/page";
import { CourseEditorPage } from "../app/main/course/editor/page"; import { CourseEditorPage } from "../app/main/course/editor/page";
import { MainLayout } from "../components/layout/main/MainLayout"; import { MainLayout } from "../components/layout/main/MainLayout";
import StudentCoursesPage from "../app/main/courses/student/page";
import InstructorCoursesPage from "../app/main/courses/instructor/page";
import HomePage from "../app/main/home/page";
interface CustomIndexRouteObject extends IndexRouteObject { interface CustomIndexRouteObject extends IndexRouteObject {
name?: string; name?: string;
@ -50,15 +51,32 @@ export const routes: CustomRouteObject[] = [
}, },
children: [ children: [
{ {
path: "courses", element: <MainLayout></MainLayout>,
children: [
{
index: true, index: true,
element: <WithAuth><MainLayout><CoursesPage></CoursesPage></MainLayout></WithAuth> element: <HomePage />
},
{
path: "courses",
children: [
{
path: "student",
element: <WithAuth><StudentCoursesPage /></WithAuth>
},
{
path: "instructor",
element: <WithAuth><InstructorCoursesPage /></WithAuth>
}
]
}
]
}, },
{ {
path: "course", path: "course",
children: [{ children: [{
path: "manage", path: ":id?/manage", // 使用 ? 表示 id 参数是可选的
element: <CourseEditorPage></CourseEditorPage> element: <CourseEditorPage />
}] }]
}, },
{ {

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,93 @@
server {
listen 80;
server_name 192.168.12.77;
# 基础优化配置
tcp_nopush on;
tcp_nodelay on;
types_hash_max_size 2048;
# Gzip 压缩配置
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
text/plain
text/css
application/json
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript;
# 默认首页配置
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# 文件缓存配置
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
try_files $uri $uri/ /index.html;
}
# 文件上传处理配置
location /uploads/ {
alias /data/uploads/;
# 文件传输优化
sendfile on;
tcp_nopush on;
aio on;
directio 512;
# 认证配置
auth_request /auth-file;
auth_request_set $auth_status $upstream_status;
auth_request_set $auth_user_id $upstream_http_x_user_id;
auth_request_set $auth_resource_type $upstream_http_x_resource_type;
# 缓存控制
expires 0;
add_header Cache-Control "private, no-transform";
add_header X-User-Id $auth_user_id;
add_header X-Resource-Type $auth_resource_type;
# 带宽控制
limit_rate 102400k;
limit_rate_after 100m;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers'
'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'
always;
}
# 认证服务配置
location = /auth-file {
internal;
proxy_pass http://192.168.12.77:3000/auth/file;
# 请求优化
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# 请求信息传递
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Host $host;
proxy_set_header X-Query-Params $query_string;
}
}

View File

@ -0,0 +1,93 @@
server {
listen 80;
server_name ${SERVER_IP};
# 基础优化配置
tcp_nopush on;
tcp_nodelay on;
types_hash_max_size 2048;
# Gzip 压缩配置
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types
text/plain
text/css
application/json
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript;
# 默认首页配置
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# 文件缓存配置
open_file_cache max=1000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
try_files $uri $uri/ /index.html;
}
# 文件上传处理配置
location /uploads/ {
alias /data/uploads/;
# 文件传输优化
sendfile on;
tcp_nopush on;
aio on;
directio 512;
# 认证配置
auth_request /auth-file;
auth_request_set $auth_status $upstream_status;
auth_request_set $auth_user_id $upstream_http_x_user_id;
auth_request_set $auth_resource_type $upstream_http_x_resource_type;
# 缓存控制
expires 0;
add_header Cache-Control "private, no-transform";
add_header X-User-Id $auth_user_id;
add_header X-Resource-Type $auth_resource_type;
# 带宽控制
limit_rate 102400k;
limit_rate_after 100m;
# CORS 配置
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers'
'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization'
always;
}
# 认证服务配置
location = /auth-file {
internal;
proxy_pass http://${SERVER_IP}:3000/auth/file;
# 请求优化
proxy_pass_request_body off;
proxy_set_header Content-Length "";
# 请求信息传递
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Original-Method $request_method;
proxy_set_header Host $host;
proxy_set_header X-Query-Params $query_string;
}
}

25
config/nginx/entrypoint.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/sh
# 处理所有 .template 结尾的文件
for template in /etc/nginx/conf.d/*.template; do
[ ! -f "$template" ] && echo "No template files found" && continue
# 将输出文件名改为 .conf 结尾
conf="${template%.template}.conf"
echo "Processing $template"
if envsubst '$SERVER_IP' < "$template" > "$conf"; then
echo "Replaced $conf successfully"
else
echo "Failed to replace $conf"
fi
done
# Check if the index.html file exists before processing
if [ -f "/usr/share/nginx/html/index.html" ]; then
# Use envsubst to replace environment variable placeholders
envsubst < /usr/share/nginx/html/index.html > /usr/share/nginx/html/index.html.tmp
mv /usr/share/nginx/html/index.html.tmp /usr/share/nginx/html/index.html
else
echo "Info: /usr/share/nginx/html/index.html does not exist , skip replace env"
exit 1
fi
# Run nginx to serve static files
exec nginx -g "daemon off;"

32
config/nginx/nginx.conf Executable file
View File

@ -0,0 +1,32 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main
'$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
# include /etc/nginx/conf.d/*.conf;
include /etc/nginx/conf.d/web.conf;
# include /etc/nginx/conf.d/file.conf;
}

View File

@ -11,38 +11,45 @@ export function useCourse() {
// Mutations // Mutations
create: api.course.create.useMutation({ create: api.course.create.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()
}, },
}), }),
update: api.course.update.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()
}, },
}), }),
createMany: api.course.createMany.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()
}, },
}), }),
deleteMany: api.course.deleteMany.useMutation({ deleteMany: api.course.deleteMany.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
}, },
}), }),
softDeleteByIds: api.course.softDeleteByIds.useMutation({ softDeleteByIds: api.course.softDeleteByIds.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
}, },
}), }),
updateOrder: api.course.updateOrder.useMutation({ updateOrder: api.course.updateOrder.useMutation({
onSuccess: () => { onSuccess: () => {
utils.course.findMany.invalidate(); utils.course.findMany.invalidate();
utils.course.findManyWithCursor.invalidate(); utils.course.findManyWithCursor.invalidate();
utils.course.findManyWithPagination.invalidate()
}, },
}) })
}; };

View File

@ -19,3 +19,15 @@ export function mergeIfDefined(
return acc; return acc;
}, { ...obj1 }); // 使用对象展开运算符创建新对象,确保原始对象不被修改 }, { ...obj1 }); // 使用对象展开运算符创建新对象,确保原始对象不被修改
} }
interface Option<T> {
label: string;
value: T;
}
export function convertToOptions<T extends string | number>(obj: Record<T, string>): Option<T>[] {
return (Object.entries(obj) as [T, string][]).map(([value, label]) => ({
label,
value
}));
}

View File

@ -107,6 +107,7 @@ model Staff {
registerToken String? registerToken String?
enrollments Enrollment[] enrollments Enrollment[]
teachedCourses CourseInstructor[] teachedCourses CourseInstructor[]
ownedResources Resource[]
@@index([officerId]) @@index([officerId])
@@index([deptId]) @@index([deptId])
@ -305,6 +306,7 @@ model Course {
terms Term[] @relation("course_term") // 课程学期 terms Term[] @relation("course_term") // 课程学期
instructors CourseInstructor[] // 课程讲师团队 instructors CourseInstructor[] // 课程讲师团队
sections Section[] // 课程章节结构 sections Section[] // 课程章节结构
lectures Lecture[]
enrollments Enrollment[] // 学生报名记录 enrollments Enrollment[] // 学生报名记录
reviews Post[] // 学员课程评价 reviews Post[] // 学员课程评价
@ -378,8 +380,10 @@ model Lecture {
// 关联关系 // 关联关系
resources Resource[] resources Resource[]
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade) section Section? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
sectionId String @map("section_id") sectionId String? @map("section_id")
course Course? @relation(fields: [courseId], references: [id], onDelete: Cascade)
courseId String? @map("course_id")
comments Post[] comments Post[]
visits Visit[] visits Visit[]
@ -427,23 +431,40 @@ model CourseInstructor {
model Resource { model Resource {
id String @id @default(cuid()) @map("id") id String @id @default(cuid()) @map("id")
title String @map("title") title String? @map("title")
description String? @map("description") description String? @map("description")
type String @map("type") type String? @map("type")
url String @map("url") // 存储信息
fileType String? @map("file_type") filename String?
fileSize Int? @map("file_size") fileId String?
downloadCount Int @default(0) @map("download_count") url String?
createdAt DateTime @default(now()) @map("created_at") hash String?
updatedAt DateTime @updatedAt @map("updated_at") // 元数据
metadata Json? @map("metadata")
// 处理状态控制
processStatus String?
lectures Lecture[] // 审计字段
posts Post[] createdAt DateTime? @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
createdBy String? @map("created_by")
updatedBy String? @map("updated_by")
deletedAt DateTime? @map("deleted_at")
isPublic Boolean? @default(true) @map("is_public")
owner Staff? @relation(fields: [ownerId], references: [id])
ownerId String? @map("owner_id")
post Post? @relation(fields: [postId], references: [id])
postId String? @map("post_id")
lecture Lecture? @relation(fields: [lectureId], references: [id])
lectureId String? @map("lecture_id")
// 索引
@@index([type]) @@index([type])
@@index([createdAt])
@@map("resource") @@map("resource")
} }
model Node { model Node {
id String @id @default(cuid()) @map("id") id String @id @default(cuid()) @map("id")
title String @map("title") title String @map("title")

View File

@ -15,5 +15,4 @@ export const db = (() => {
} }
return prisma; return prisma;
} }
throw new Error('PrismaClient is not available in browser environment');
})() as PrismaClient; })() as PrismaClient;

View File

@ -4,6 +4,7 @@ export enum SocketMsgType {
export enum PostType { export enum PostType {
POST = "post", POST = "post",
POST_COMMENT = "post_comment", POST_COMMENT = "post_comment",
COURSE_REVIEW = "course_review"
} }
export enum TaxonomySlug { export enum TaxonomySlug {
CATEGORY = "category", CATEGORY = "category",
@ -16,6 +17,20 @@ export enum VisitType {
} }
export enum StorageProvider {
LOCAL = 'LOCAL',
S3 = 'S3',
OSS = 'OSS',
COS = 'COS',
CDN = 'CDN'
}
export enum ResourceProcessStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
}
export enum ObjectType { export enum ObjectType {
DEPARTMENT = "department", DEPARTMENT = "department",
STAFF = "staff", STAFF = "staff",
@ -72,7 +87,6 @@ export enum RolePerms {
MANAGE_ANY_ROLE = "MANAGE_ANY_ROLE", MANAGE_ANY_ROLE = "MANAGE_ANY_ROLE",
MANAGE_DOM_ROLE = "MANAGE_DOM_ROLE", MANAGE_DOM_ROLE = "MANAGE_DOM_ROLE",
} }
export enum AppConfigSlug { export enum AppConfigSlug {
BASE_SETTING = "base_setting", BASE_SETTING = "base_setting",
} }
@ -90,7 +104,6 @@ export enum ResourceType {
ZIP = "zip", // 压缩包文件 ZIP = "zip", // 压缩包文件
OTHER = "other" // 其他未分类资源 OTHER = "other" // 其他未分类资源
} }
// 课程等级的枚举,描述了不同学习水平的课程 // 课程等级的枚举,描述了不同学习水平的课程
export enum CourseLevel { export enum CourseLevel {
BEGINNER = "beginner", // 初级课程,适合初学者 BEGINNER = "beginner", // 初级课程,适合初学者
@ -114,6 +127,12 @@ export enum CourseStatus {
PUBLISHED = "published", // 已发布的课程,可以被学员报名学习 PUBLISHED = "published", // 已发布的课程,可以被学员报名学习
ARCHIVED = "archived" // 已归档的课程,不再对外展示 ARCHIVED = "archived" // 已归档的课程,不再对外展示
} }
export const CourseStatusLabel: Record<CourseStatus, string> = {
[CourseStatus.DRAFT]: "草稿",
[CourseStatus.UNDER_REVIEW]: "审核中",
[CourseStatus.PUBLISHED]: "已发布",
[CourseStatus.ARCHIVED]: "已归档"
};
// 报名状态的枚举,描述了用户报名参加课程的不同状态 // 报名状态的枚举,描述了用户报名参加课程的不同状态
export enum EnrollmentStatus { export enum EnrollmentStatus {
@ -129,3 +148,44 @@ export enum InstructorRole {
MAIN = "main", // 主讲教师 MAIN = "main", // 主讲教师
ASSISTANT = "assistant" // 助教 ASSISTANT = "assistant" // 助教
} }
export const EnrollmentStatusLabel = {
[EnrollmentStatus.PENDING]: '待处理',
[EnrollmentStatus.ACTIVE]: '进行中',
[EnrollmentStatus.COMPLETED]: '已完成',
[EnrollmentStatus.CANCELLED]: '已取消',
[EnrollmentStatus.REFUNDED]: '已退款'
};
export const InstructorRoleLabel = {
[InstructorRole.MAIN]: '主讲教师',
[InstructorRole.ASSISTANT]: '助教'
};
export const ResourceTypeLabel = {
[ResourceType.VIDEO]: '视频',
[ResourceType.PDF]: 'PDF文档',
[ResourceType.DOC]: 'Word文档',
[ResourceType.EXCEL]: 'Excel表格',
[ResourceType.PPT]: 'PPT演示文稿',
[ResourceType.CODE]: '代码文件',
[ResourceType.LINK]: '链接',
[ResourceType.IMAGE]: '图片',
[ResourceType.AUDIO]: '音频',
[ResourceType.ZIP]: '压缩包',
[ResourceType.OTHER]: '其他'
};
export const CourseLevelLabel = {
[CourseLevel.BEGINNER]: '初级',
[CourseLevel.INTERMEDIATE]: '中级',
[CourseLevel.ADVANCED]: '高级',
[CourseLevel.ALL_LEVELS]: '不限级别'
};
export const LessonTypeLabel = {
[LessonType.VIDEO]: '视频课程',
[LessonType.ARTICLE]: '图文课程',
[LessonType.QUIZ]: '测验',
[LessonType.ASSIGNMENT]: '作业'
};

View File

@ -246,6 +246,7 @@ export const TermMethodSchema = {
termIds: z.array(z.string().nullish()).nullish(), termIds: z.array(z.string().nullish()).nullish(),
parentId: z.string().nullish(), parentId: z.string().nullish(),
taxonomyId: z.string().nullish(), taxonomyId: z.string().nullish(),
domainId: z.string().nullish(),
}), }),
findMany: z.object({ findMany: z.object({
keyword: z.string().nullish(), keyword: z.string().nullish(),

View File

@ -1 +1 @@
export type IconName = 'align-center' | 'align-justify' | 'align-left' | 'align-right' | 'arrow-drop-down' | 'bold' | 'check' | 'content' | 'copy' | 'edit' | 'get-text' | 'home' | 'horizontal-rule' | 'image' | 'italic' | 'link-off' | 'link' | 'logout' | 'react' | 'redo' | 'share' | 'strike' | 'text-indent' | 'text-outdent' | 'underline' | 'undo' | 'zoomin' | 'zoomout'; export type IconName = 'account-location' | 'add' | 'admin-outlined' | 'airport' | 'align-center' | 'align-justify' | 'align-left' | 'align-right' | 'approve' | 'arrow-drop-down' | 'blocks-group' | 'bold' | 'caret-right' | 'category-outline' | 'check-one' | 'check' | 'config' | 'content' | 'copy' | 'cube-duotone' | 'date-time' | 'delete' | 'edit' | 'error-duotone' | 'error-outline' | 'exit' | 'filter' | 'fluent-person' | 'get-text' | 'group-work' | 'health-circle' | 'history' | 'home' | 'horizontal-rule' | 'image' | 'inbox' | 'italic' | 'link-off' | 'link' | 'list' | 'logout' | 'loop' | 'more' | 'note' | 'number-symbol' | 'org' | 'people-32' | 'people-group' | 'people-plus' | 'people' | 'person-board' | 'person-hair' | 'person-home' | 'plane-takeoff' | 'plane' | 'progress' | 'radar-chart' | 'react' | 'redo' | 'right-line' | 'seal-check' | 'search' | 'setting' | 'share' | 'strike' | 'subject-rounded' | 'sum' | 'target' | 'text-indent' | 'text-outdent' | 'time' | 'underline' | 'undo' | 'user-id' | 'work' | 'zoomin' | 'zoomout';

File diff suppressed because it is too large Load Diff

1
web-dist/error.html Normal file
View File

@ -0,0 +1 @@
<h1>error</h1>

1
web-dist/index.html Normal file
View File

@ -0,0 +1 @@
<h1>test1</h1>