01030748
This commit is contained in:
parent
cfa4be626d
commit
6f39331b70
|
@ -67,3 +67,5 @@ yarn-error.log*
|
||||||
|
|
||||||
# Ignore .idea files in the Expo monorepo
|
# Ignore .idea files in the Expo monorepo
|
||||||
**/.idea/
|
**/.idea/
|
||||||
|
|
||||||
|
uploads
|
85
Dockerfile
85
Dockerfile
|
@ -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
|
|
@ -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
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -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 { }
|
||||||
|
|
|
@ -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;
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@ async function bootstrap() {
|
||||||
trpc.applyMiddleware(app);
|
trpc.applyMiddleware(app);
|
||||||
|
|
||||||
const port = process.env.SERVER_PORT || 3000;
|
const port = process.env.SERVER_PORT || 3000;
|
||||||
|
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
});
|
|
@ -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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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> {
|
||||||
|
|
||||||
|
// }
|
||||||
|
// }
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
|
|
@ -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
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
export type CustomJobType = "pushMessage" | "updateTroubleViewCount"
|
|
||||||
export type updateViewCountJobData = {
|
|
||||||
id: string
|
|
||||||
}
|
|
|
@ -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 } })
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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',
|
{
|
||||||
processors: [join(__dirname, 'worker/processor.js')],
|
name: 'general',
|
||||||
})
|
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 { }
|
||||||
|
|
|
@ -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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export enum QueueJobType {
|
||||||
|
UPDATE_STATS = "update_stats",
|
||||||
|
FILE_PROCESS = "file_process"
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
} catch (error: any) {
|
||||||
case "pushMessage":
|
logger.error(`Error processing stats update job: ${error.message}`, error.stack);
|
||||||
|
|
||||||
logger.log(`push message ${job.data.id}`)
|
|
||||||
pushService.messagePush(job.data.registerToken, job.data.messageContent)
|
|
||||||
break
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 { }
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
};
|
|
@ -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;"
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
|
@ -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获取
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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" />,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 (
|
||||||
<motion.div
|
<div>
|
||||||
variants={container}
|
<motion.div
|
||||||
initial="hidden"
|
variants={container}
|
||||||
animate="show"
|
initial="hidden"
|
||||||
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
animate="show"
|
||||||
>
|
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
{courses.map((course) => renderItem(course))}
|
>
|
||||||
</motion.div>
|
{courses.map((course) => (
|
||||||
|
<motion.div key={course.id}>
|
||||||
|
{renderItem(course)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={onPageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
|
@ -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]}>
|
||||||
<div className="hidden md:flex items-center text-gray-500 text-sm">
|
{course?.status ? CourseStatusLabel[course.status] : CourseStatusLabel[CourseStatus.DRAFT]}
|
||||||
<ClockIcon className="w-4 h-4 mr-1" />
|
</Tag>
|
||||||
<span>总时长 12:30:00</span>
|
{course?.totalDuration ? (
|
||||||
</div>
|
<div className="hidden md:flex items-center text-gray-500 text-sm">
|
||||||
|
<ClockIcon className="w-4 h-4 mr-1" />
|
||||||
|
<span>总时长 {course?.totalDuration}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleSubmit(onSubmit)} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
<Button
|
||||||
|
disabled={course ? (!isValid || !isDirty) : !isValid}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
保存
|
保存
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
|
@ -178,4 +190,4 @@ export default function TermSelect({
|
||||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
strokeWidth="2"
|
||||||
|
fill="white"
|
||||||
|
filter="url(#glow)"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="150"
|
||||||
|
y1="82"
|
||||||
|
x2="140"
|
||||||
|
y2="92"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
</motion.g>
|
</motion.g>
|
||||||
|
|
||||||
{/* Decorative Lines */}
|
{/* Atom Structure */}
|
||||||
<motion.g
|
<motion.g
|
||||||
stroke="#C7D2FE"
|
variants={{
|
||||||
strokeWidth="2"
|
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"
|
strokeLinecap="round"
|
||||||
initial={{ pathLength: 0 }}
|
strokeDasharray="4,4"
|
||||||
animate={{ pathLength: 1 }}
|
variants={{
|
||||||
transition={{ duration: 1, delay: 0.8 }}
|
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>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
|
@ -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}
|
||||||
`}
|
`}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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';
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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>,
|
||||||
index: true,
|
children: [
|
||||||
element: <WithAuth><MainLayout><CoursesPage></CoursesPage></MainLayout></WithAuth>
|
{
|
||||||
|
index: true,
|
||||||
|
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 />
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;"
|
|
@ -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;
|
||||||
|
}
|
|
@ -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()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
}));
|
||||||
|
}
|
|
@ -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[]
|
||||||
|
|
||||||
|
@ -426,24 +430,41 @@ 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")
|
||||||
|
|
|
@ -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;
|
|
@ -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 {
|
||||||
|
@ -128,4 +147,45 @@ export enum EnrollmentStatus {
|
||||||
export enum InstructorRole {
|
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]: '作业'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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';
|
||||||
|
|
1690
pnpm-lock.yaml
1690
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
||||||
|
<h1>error</h1>
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>test1</h1>
|
Loading…
Reference in New Issue