trae-bot commited on
Commit
f45e448
·
1 Parent(s): d014d32

2026032101

Browse files
.gitignore CHANGED
@@ -7,3 +7,4 @@ backend/uploads
7
  .trae
8
  npm-debug.log*
9
  yarn-error.log*
 
 
7
  .trae
8
  npm-debug.log*
9
  yarn-error.log*
10
+
backend/src/admin/admin.controller.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
  Post,
4
  Get,
5
  Put,
 
6
  Param,
7
  Body,
8
  UseGuards,
@@ -35,6 +36,12 @@ export class AdminController {
35
  return { success: true, data };
36
  }
37
 
 
 
 
 
 
 
38
  @Get('orders')
39
  async getOrders() {
40
  const data = await this.adminService.getOrders();
 
3
  Post,
4
  Get,
5
  Put,
6
+ Delete,
7
  Param,
8
  Body,
9
  UseGuards,
 
36
  return { success: true, data };
37
  }
38
 
39
+ @Delete('courses/:id')
40
+ async deleteCourse(@Param('id') id: string) {
41
+ await this.adminService.deleteCourse(Number(id));
42
+ return { success: true };
43
+ }
44
+
45
  @Get('orders')
46
  async getOrders() {
47
  const data = await this.adminService.getOrders();
backend/src/admin/admin.service.ts CHANGED
@@ -24,6 +24,10 @@ export class AdminService {
24
  return this.courseRepository.findOne({ where: { id } });
25
  }
26
 
 
 
 
 
27
  async getOrders() {
28
  return this.orderRepository.find({
29
  relations: ['user', 'course'],
 
24
  return this.courseRepository.findOne({ where: { id } });
25
  }
26
 
27
+ async deleteCourse(id: number) {
28
+ await this.courseRepository.delete(id);
29
+ }
30
+
31
  async getOrders() {
32
  return this.orderRepository.find({
33
  relations: ['user', 'course'],
backend/src/admin/dto/create-course.dto.ts CHANGED
@@ -24,4 +24,16 @@ export class CreateCourseDto {
24
  @IsString()
25
  @IsOptional()
26
  category?: string;
 
 
 
 
 
 
 
 
 
 
 
 
27
  }
 
24
  @IsString()
25
  @IsOptional()
26
  category?: string;
27
+
28
+ @IsNumber()
29
+ @IsOptional()
30
+ viewCount?: number;
31
+
32
+ @IsNumber()
33
+ @IsOptional()
34
+ likeCount?: number;
35
+
36
+ @IsNumber()
37
+ @IsOptional()
38
+ starCount?: number;
39
  }
backend/src/app.module.ts CHANGED
@@ -8,6 +8,7 @@ import { Course } from './entities/course.entity';
8
  import { Order } from './entities/order.entity';
9
  import { Payment } from './entities/payment.entity';
10
  import { UserCourse } from './entities/user-course.entity';
 
11
  import { AuthModule } from './auth/auth.module';
12
  import { CoursesModule } from './courses/courses.module';
13
  import { OrdersModule } from './orders/orders.module';
@@ -22,7 +23,7 @@ import { PaymentModule } from './payment/payment.module';
22
  TypeOrmModule.forRoot({
23
  type: 'sqlite',
24
  database: 'course_subscription.sqlite',
25
- entities: [User, Course, Order, Payment, UserCourse],
26
  synchronize: true, // Auto-create tables in dev
27
  }),
28
  AuthModule,
 
8
  import { Order } from './entities/order.entity';
9
  import { Payment } from './entities/payment.entity';
10
  import { UserCourse } from './entities/user-course.entity';
11
+ import { UserStar } from './entities/user-star.entity';
12
  import { AuthModule } from './auth/auth.module';
13
  import { CoursesModule } from './courses/courses.module';
14
  import { OrdersModule } from './orders/orders.module';
 
23
  TypeOrmModule.forRoot({
24
  type: 'sqlite',
25
  database: 'course_subscription.sqlite',
26
+ entities: [User, Course, Order, Payment, UserCourse, UserStar],
27
  synchronize: true, // Auto-create tables in dev
28
  }),
29
  AuthModule,
backend/src/auth/auth.service.ts CHANGED
@@ -21,21 +21,21 @@ export class AuthService implements OnModuleInit {
21
  ) {}
22
 
23
  async onModuleInit() {
24
- const adminPhone = '12345678912';
25
  const adminExists = await this.userRepository.findOne({
26
- where: { phone: adminPhone },
27
  });
28
  if (!adminExists) {
29
  const salt = await bcrypt.genSalt();
30
  const passwordHash = await bcrypt.hash('123456', salt);
31
  const adminUser = this.userRepository.create({
32
- phone: adminPhone,
33
  passwordHash,
34
  nickname: 'Admin',
35
  role: UserRole.ADMIN,
36
  });
37
  await this.userRepository.save(adminUser);
38
- console.log(`Admin user initialized: ${adminPhone}`);
39
  } else {
40
  // Ensure the role is ADMIN and update password if needed
41
  if (adminExists.role !== UserRole.ADMIN) {
@@ -46,15 +46,15 @@ export class AuthService implements OnModuleInit {
46
  }
47
 
48
  async register(registerDto: RegisterDto) {
49
- const { phone, password, smsCode } = registerDto;
50
 
51
- // In a real app, verify smsCode here
52
- if (smsCode !== '123456') {
53
- throw new BadRequestException('Invalid SMS code');
54
  }
55
 
56
  const existingUser = await this.userRepository.findOne({
57
- where: { phone },
58
  });
59
  if (existingUser) {
60
  throw new BadRequestException('User already exists');
@@ -64,20 +64,20 @@ export class AuthService implements OnModuleInit {
64
  const passwordHash = await bcrypt.hash(password, salt);
65
 
66
  const user = this.userRepository.create({
67
- phone,
68
  passwordHash,
69
- nickname: `User_${phone.slice(-4)}`,
70
  role: UserRole.USER,
71
  });
72
 
73
  await this.userRepository.save(user);
74
 
75
- return this.login({ phone, password });
76
  }
77
 
78
  async login(loginDto: LoginDto) {
79
- const { phone, password } = loginDto;
80
- const user = await this.userRepository.findOne({ where: { phone } });
81
 
82
  if (!user) {
83
  throw new UnauthorizedException('Invalid credentials');
@@ -88,7 +88,7 @@ export class AuthService implements OnModuleInit {
88
  throw new UnauthorizedException('Invalid credentials');
89
  }
90
 
91
- const payload = { sub: user.id, phone: user.phone, role: user.role };
92
  return {
93
  userId: user.id,
94
  token: this.jwtService.sign(payload),
@@ -102,7 +102,6 @@ export class AuthService implements OnModuleInit {
102
  where: { id: userId },
103
  select: [
104
  'id',
105
- 'phone',
106
  'email',
107
  'nickname',
108
  'avatar',
 
21
  ) {}
22
 
23
  async onModuleInit() {
24
+ const adminEmail = 'admin@example.com';
25
  const adminExists = await this.userRepository.findOne({
26
+ where: { email: adminEmail },
27
  });
28
  if (!adminExists) {
29
  const salt = await bcrypt.genSalt();
30
  const passwordHash = await bcrypt.hash('123456', salt);
31
  const adminUser = this.userRepository.create({
32
+ email: adminEmail,
33
  passwordHash,
34
  nickname: 'Admin',
35
  role: UserRole.ADMIN,
36
  });
37
  await this.userRepository.save(adminUser);
38
+ console.log(`Admin user initialized: ${adminEmail}`);
39
  } else {
40
  // Ensure the role is ADMIN and update password if needed
41
  if (adminExists.role !== UserRole.ADMIN) {
 
46
  }
47
 
48
  async register(registerDto: RegisterDto) {
49
+ const { email, password, emailCode } = registerDto;
50
 
51
+ // In a real app, verify emailCode here
52
+ if (emailCode !== '123456') {
53
+ throw new BadRequestException('Invalid Email code');
54
  }
55
 
56
  const existingUser = await this.userRepository.findOne({
57
+ where: { email },
58
  });
59
  if (existingUser) {
60
  throw new BadRequestException('User already exists');
 
64
  const passwordHash = await bcrypt.hash(password, salt);
65
 
66
  const user = this.userRepository.create({
67
+ email,
68
  passwordHash,
69
+ nickname: `User_${email.split('@')[0].slice(0, 6)}`,
70
  role: UserRole.USER,
71
  });
72
 
73
  await this.userRepository.save(user);
74
 
75
+ return this.login({ email, password });
76
  }
77
 
78
  async login(loginDto: LoginDto) {
79
+ const { email, password } = loginDto;
80
+ const user = await this.userRepository.findOne({ where: { email } });
81
 
82
  if (!user) {
83
  throw new UnauthorizedException('Invalid credentials');
 
88
  throw new UnauthorizedException('Invalid credentials');
89
  }
90
 
91
+ const payload = { sub: user.id, email: user.email, role: user.role };
92
  return {
93
  userId: user.id,
94
  token: this.jwtService.sign(payload),
 
102
  where: { id: userId },
103
  select: [
104
  'id',
 
105
  'email',
106
  'nickname',
107
  'avatar',
backend/src/auth/dto/login.dto.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { IsString, IsNotEmpty } from 'class-validator';
2
 
3
  export class LoginDto {
4
- @IsString()
5
  @IsNotEmpty()
6
- phone: string;
7
 
8
  @IsString()
9
  @IsNotEmpty()
 
1
+ import { IsString, IsNotEmpty, IsEmail } from 'class-validator';
2
 
3
  export class LoginDto {
4
+ @IsEmail()
5
  @IsNotEmpty()
6
+ email: string;
7
 
8
  @IsString()
9
  @IsNotEmpty()
backend/src/auth/dto/register.dto.ts CHANGED
@@ -1,10 +1,9 @@
1
- import { IsString, IsNotEmpty, Length } from 'class-validator';
2
 
3
  export class RegisterDto {
4
- @IsString()
5
  @IsNotEmpty()
6
- @Length(11, 11)
7
- phone: string;
8
 
9
  @IsString()
10
  @IsNotEmpty()
@@ -13,5 +12,5 @@ export class RegisterDto {
13
 
14
  @IsString()
15
  @IsNotEmpty()
16
- smsCode: string;
17
  }
 
1
+ import { IsString, IsNotEmpty, Length, IsEmail } from 'class-validator';
2
 
3
  export class RegisterDto {
4
+ @IsEmail()
5
  @IsNotEmpty()
6
+ email: string;
 
7
 
8
  @IsString()
9
  @IsNotEmpty()
 
12
 
13
  @IsString()
14
  @IsNotEmpty()
15
+ emailCode: string;
16
  }
backend/src/auth/strategies/jwt.strategy.ts CHANGED
@@ -28,6 +28,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
28
  throw new UnauthorizedException();
29
  }
30
  // Return data to be attached to the request object
31
- return { userId: user.id, phone: user.phone, role: user.role };
32
  }
33
  }
 
28
  throw new UnauthorizedException();
29
  }
30
  // Return data to be attached to the request object
31
+ return { userId: user.id, email: user.email, role: user.role };
32
  }
33
  }
backend/src/courses/courses.controller.ts CHANGED
@@ -1,6 +1,7 @@
1
  import {
2
  Controller,
3
  Get,
 
4
  Param,
5
  UseGuards,
6
  Request,
@@ -26,12 +27,38 @@ export class CoursesController {
26
  return { success: true, data };
27
  }
28
 
 
 
 
 
 
 
 
29
  @Get(':id')
30
  async findOne(@Param('id', ParseIntPipe) id: number) {
31
  const data = await this.coursesService.findOne(id);
32
  return { success: true, data };
33
  }
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  @UseGuards(JwtAuthGuard)
36
  @Get(':id/access')
37
  async getAccess(@Param('id', ParseIntPipe) id: number, @Request() req) {
 
1
  import {
2
  Controller,
3
  Get,
4
+ Post,
5
  Param,
6
  UseGuards,
7
  Request,
 
27
  return { success: true, data };
28
  }
29
 
30
+ @UseGuards(JwtAuthGuard)
31
+ @Get('my-stars')
32
+ async findMyStars(@Request() req) {
33
+ const data = await this.coursesService.getUserStars(req.user.userId);
34
+ return { success: true, data };
35
+ }
36
+
37
  @Get(':id')
38
  async findOne(@Param('id', ParseIntPipe) id: number) {
39
  const data = await this.coursesService.findOne(id);
40
  return { success: true, data };
41
  }
42
 
43
+ @Post(':id/view')
44
+ async incrementViewCount(@Param('id', ParseIntPipe) id: number) {
45
+ await this.coursesService.incrementViewCount(id);
46
+ return { success: true };
47
+ }
48
+
49
+ @Post(':id/like')
50
+ async toggleLike(@Param('id', ParseIntPipe) id: number) {
51
+ await this.coursesService.toggleLike(id);
52
+ return { success: true };
53
+ }
54
+
55
+ @UseGuards(JwtAuthGuard)
56
+ @Post(':id/star')
57
+ async toggleStar(@Param('id', ParseIntPipe) id: number, @Request() req) {
58
+ await this.coursesService.toggleStar(id, req.user.userId);
59
+ return { success: true };
60
+ }
61
+
62
  @UseGuards(JwtAuthGuard)
63
  @Get(':id/access')
64
  async getAccess(@Param('id', ParseIntPipe) id: number, @Request() req) {
backend/src/courses/courses.module.ts CHANGED
@@ -4,9 +4,10 @@ import { CoursesService } from './courses.service';
4
  import { CoursesController } from './courses.controller';
5
  import { Course } from '../entities/course.entity';
6
  import { UserCourse } from '../entities/user-course.entity';
 
7
 
8
  @Module({
9
- imports: [TypeOrmModule.forFeature([Course, UserCourse])],
10
  providers: [CoursesService],
11
  controllers: [CoursesController],
12
  exports: [CoursesService],
 
4
  import { CoursesController } from './courses.controller';
5
  import { Course } from '../entities/course.entity';
6
  import { UserCourse } from '../entities/user-course.entity';
7
+ import { UserStar } from '../entities/user-star.entity';
8
 
9
  @Module({
10
+ imports: [TypeOrmModule.forFeature([Course, UserCourse, UserStar])],
11
  providers: [CoursesService],
12
  controllers: [CoursesController],
13
  exports: [CoursesService],
backend/src/courses/courses.service.ts CHANGED
@@ -7,6 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm';
7
  import { Repository } from 'typeorm';
8
  import { Course } from '../entities/course.entity';
9
  import { UserCourse } from '../entities/user-course.entity';
 
10
 
11
  @Injectable()
12
  export class CoursesService {
@@ -15,12 +16,14 @@ export class CoursesService {
15
  private courseRepository: Repository<Course>,
16
  @InjectRepository(UserCourse)
17
  private userCourseRepository: Repository<UserCourse>,
 
 
18
  ) {}
19
 
20
  async findAll() {
21
  return this.courseRepository.find({
22
  where: { isActive: true },
23
- select: ['id', 'title', 'description', 'coverImage', 'price', 'category'],
24
  order: { createdAt: 'DESC' },
25
  });
26
  }
@@ -28,7 +31,7 @@ export class CoursesService {
28
  async findOne(id: number) {
29
  const course = await this.courseRepository.findOne({
30
  where: { id, isActive: true },
31
- select: ['id', 'title', 'description', 'coverImage', 'price', 'category'],
32
  });
33
 
34
  if (!course) {
@@ -38,6 +41,51 @@ export class CoursesService {
38
  return course;
39
  }
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  async getUserCourses(userId: number) {
42
  const userCourses = await this.userCourseRepository.find({
43
  where: { userId },
 
7
  import { Repository } from 'typeorm';
8
  import { Course } from '../entities/course.entity';
9
  import { UserCourse } from '../entities/user-course.entity';
10
+ import { UserStar } from '../entities/user-star.entity';
11
 
12
  @Injectable()
13
  export class CoursesService {
 
16
  private courseRepository: Repository<Course>,
17
  @InjectRepository(UserCourse)
18
  private userCourseRepository: Repository<UserCourse>,
19
+ @InjectRepository(UserStar)
20
+ private userStarRepository: Repository<UserStar>,
21
  ) {}
22
 
23
  async findAll() {
24
  return this.courseRepository.find({
25
  where: { isActive: true },
26
+ select: ['id', 'title', 'description', 'coverImage', 'price', 'category', 'viewCount', 'likeCount', 'starCount'],
27
  order: { createdAt: 'DESC' },
28
  });
29
  }
 
31
  async findOne(id: number) {
32
  const course = await this.courseRepository.findOne({
33
  where: { id, isActive: true },
34
+ select: ['id', 'title', 'description', 'coverImage', 'price', 'category', 'viewCount', 'likeCount', 'starCount'],
35
  });
36
 
37
  if (!course) {
 
41
  return course;
42
  }
43
 
44
+ async incrementViewCount(id: number) {
45
+ await this.courseRepository.increment({ id }, 'viewCount', 1);
46
+ }
47
+
48
+ async toggleLike(id: number) {
49
+ const course = await this.courseRepository.findOne({ where: { id } });
50
+ if (course) {
51
+ await this.courseRepository.increment({ id }, 'likeCount', 1);
52
+ }
53
+ }
54
+
55
+ async toggleStar(courseId: number, userId: number) {
56
+ const existingStar = await this.userStarRepository.findOne({
57
+ where: { courseId, userId },
58
+ });
59
+
60
+ if (existingStar) {
61
+ await this.userStarRepository.remove(existingStar);
62
+ await this.courseRepository.decrement({ id: courseId }, 'starCount', 1);
63
+ } else {
64
+ const newStar = this.userStarRepository.create({ courseId, userId });
65
+ await this.userStarRepository.save(newStar);
66
+ await this.courseRepository.increment({ id: courseId }, 'starCount', 1);
67
+ }
68
+ }
69
+
70
+ async getUserStars(userId: number) {
71
+ const userStars = await this.userStarRepository.find({
72
+ where: { userId },
73
+ relations: ['course'],
74
+ });
75
+
76
+ return userStars.map((us) => ({
77
+ id: us.course.id,
78
+ title: us.course.title,
79
+ coverImage: us.course.coverImage,
80
+ description: us.course.description,
81
+ price: us.course.price,
82
+ category: us.course.category,
83
+ viewCount: us.course.viewCount,
84
+ likeCount: us.course.likeCount,
85
+ starCount: us.course.starCount,
86
+ }));
87
+ }
88
+
89
  async getUserCourses(userId: number) {
90
  const userCourses = await this.userCourseRepository.find({
91
  where: { userId },
backend/src/entities/course.entity.ts CHANGED
@@ -32,6 +32,15 @@ export class Course {
32
  @Column({ type: 'varchar', length: 50, nullable: true })
33
  category: string;
34
 
 
 
 
 
 
 
 
 
 
35
  @Column({ type: 'boolean', default: true, name: 'is_active' })
36
  isActive: boolean;
37
 
 
32
  @Column({ type: 'varchar', length: 50, nullable: true })
33
  category: string;
34
 
35
+ @Column({ type: 'int', default: 0, name: 'view_count' })
36
+ viewCount: number;
37
+
38
+ @Column({ type: 'int', default: 0, name: 'like_count' })
39
+ likeCount: number;
40
+
41
+ @Column({ type: 'int', default: 0, name: 'star_count' })
42
+ starCount: number;
43
+
44
  @Column({ type: 'boolean', default: true, name: 'is_active' })
45
  isActive: boolean;
46
 
backend/src/entities/user-star.entity.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Entity,
3
+ PrimaryGeneratedColumn,
4
+ CreateDateColumn,
5
+ ManyToOne,
6
+ JoinColumn,
7
+ Column,
8
+ } from 'typeorm';
9
+ import { User } from './user.entity';
10
+ import { Course } from './course.entity';
11
+
12
+ @Entity('user_stars')
13
+ export class UserStar {
14
+ @PrimaryGeneratedColumn()
15
+ id: number;
16
+
17
+ @Column({ name: 'user_id' })
18
+ userId: number;
19
+
20
+ @Column({ name: 'course_id' })
21
+ courseId: number;
22
+
23
+ @ManyToOne(() => User)
24
+ @JoinColumn({ name: 'user_id' })
25
+ user: User;
26
+
27
+ @ManyToOne(() => Course)
28
+ @JoinColumn({ name: 'course_id' })
29
+ course: Course;
30
+
31
+ @CreateDateColumn({ name: 'created_at' })
32
+ createdAt: Date;
33
+ }
backend/src/entities/user.entity.ts CHANGED
@@ -19,10 +19,7 @@ export class User {
19
  @PrimaryGeneratedColumn()
20
  id: number;
21
 
22
- @Column({ type: 'varchar', length: 11, unique: true })
23
- phone: string;
24
-
25
- @Column({ type: 'varchar', length: 100, unique: true, nullable: true })
26
  email: string;
27
 
28
  @Column({ type: 'varchar', length: 255, name: 'password_hash' })
 
19
  @PrimaryGeneratedColumn()
20
  id: number;
21
 
22
+ @Column({ type: 'varchar', length: 100, unique: true })
 
 
 
23
  email: string;
24
 
25
  @Column({ type: 'varchar', length: 255, name: 'password_hash' })
backend/src/main.ts CHANGED
@@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
2
  import { ValidationPipe } from '@nestjs/common';
3
  import { NestExpressApplication } from '@nestjs/platform-express';
4
  import { join } from 'path';
 
5
  import { AppModule } from './app.module';
6
 
7
  async function bootstrap() {
@@ -9,6 +10,10 @@ async function bootstrap() {
9
  app.enableCors();
10
  app.useGlobalPipes(new ValidationPipe());
11
 
 
 
 
 
12
  // Serve static files from the uploads directory
13
  app.useStaticAssets(join(__dirname, '..', 'uploads'), {
14
  prefix: '/uploads/',
 
2
  import { ValidationPipe } from '@nestjs/common';
3
  import { NestExpressApplication } from '@nestjs/platform-express';
4
  import { join } from 'path';
5
+ import { json, urlencoded } from 'express';
6
  import { AppModule } from './app.module';
7
 
8
  async function bootstrap() {
 
10
  app.enableCors();
11
  app.useGlobalPipes(new ValidationPipe());
12
 
13
+ // Increase the payload size limit for rich text content
14
+ app.use(json({ limit: '50mb' }));
15
+ app.use(urlencoded({ extended: true, limit: '50mb' }));
16
+
17
  // Serve static files from the uploads directory
18
  app.useStaticAssets(join(__dirname, '..', 'uploads'), {
19
  prefix: '/uploads/',
backend/src/orders/orders.service.ts CHANGED
@@ -30,8 +30,9 @@ export class OrdersService {
30
  throw new BadRequestException('Price mismatch');
31
  }
32
 
33
- // Generate simple order number
34
- const orderNo = `${new Date().getFullYear()}${(new Date().getMonth() + 1).toString().padStart(2, '0')}${new Date().getDate().toString().padStart(2, '0')}${Math.floor(
 
35
  Math.random() * 10000,
36
  )
37
  .toString()
 
30
  throw new BadRequestException('Price mismatch');
31
  }
32
 
33
+ // Generate simple order number with timestamp to avoid collision under high concurrency
34
+ const timestamp = Date.now().toString().slice(-6);
35
+ const orderNo = `${new Date().getFullYear()}${(new Date().getMonth() + 1).toString().padStart(2, '0')}${new Date().getDate().toString().padStart(2, '0')}${timestamp}${Math.floor(
36
  Math.random() * 10000,
37
  )
38
  .toString()
backend/src/payment/payment.service.ts CHANGED
@@ -36,7 +36,7 @@ export class PaymentService {
36
  throw new BadRequestException('Order is not in pending status');
37
  }
38
 
39
- const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 1000)}`;
40
 
41
  const payment = this.paymentRepository.create({
42
  orderId: order.id,
 
36
  throw new BadRequestException('Order is not in pending status');
37
  }
38
 
39
+ const paymentNo = `PAY${Date.now()}${Math.floor(Math.random() * 1000000)}`;
40
 
41
  const payment = this.paymentRepository.create({
42
  orderId: order.id,
backend/src/seed-categories.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NestFactory } from '@nestjs/core';
2
+ import { AppModule } from './app.module';
3
+ import { getRepositoryToken } from '@nestjs/typeorm';
4
+ import { Course } from './entities/course.entity';
5
+
6
+ const CATEGORIES = [
7
+ 'AI新资讯',
8
+ 'Comfyui资讯',
9
+ '满血整合包',
10
+ '闭源API接口',
11
+ '应用广场'
12
+ ];
13
+
14
+ async function bootstrap() {
15
+ const app = await NestFactory.createApplicationContext(AppModule);
16
+
17
+ const courseRepository = app.get(getRepositoryToken(Course));
18
+
19
+ const courses = await courseRepository.find();
20
+ console.log(`Found ${courses.length} courses. Updating categories...`);
21
+
22
+ for (const course of courses) {
23
+ const randomCategory = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)];
24
+ course.category = randomCategory;
25
+ await courseRepository.save(course);
26
+ console.log(`Updated course ${course.id} "${course.title}" to category: ${randomCategory}`);
27
+ }
28
+
29
+ console.log('Finished updating categories.');
30
+ await app.close();
31
+ }
32
+
33
+ bootstrap();
frontend/package-lock.json CHANGED
@@ -15,6 +15,7 @@
15
  "next": "14.2.35",
16
  "react": "^18",
17
  "react-dom": "^18",
 
18
  "tailwind-merge": "^3.5.0",
19
  "zustand": "^5.0.12"
20
  },
@@ -560,6 +561,15 @@
560
  "devOptional": true,
561
  "license": "MIT"
562
  },
 
 
 
 
 
 
 
 
 
563
  "node_modules/@types/react": {
564
  "version": "18.3.28",
565
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -1542,7 +1552,6 @@
1542
  "version": "1.0.8",
1543
  "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
1544
  "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
1545
- "dev": true,
1546
  "license": "MIT",
1547
  "dependencies": {
1548
  "call-bind-apply-helpers": "^1.0.0",
@@ -1574,7 +1583,6 @@
1574
  "version": "1.0.4",
1575
  "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
1576
  "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
1577
- "dev": true,
1578
  "license": "MIT",
1579
  "dependencies": {
1580
  "call-bind-apply-helpers": "^1.0.2",
@@ -1700,6 +1708,15 @@
1700
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
1701
  "license": "MIT"
1702
  },
 
 
 
 
 
 
 
 
 
1703
  "node_modules/clsx": {
1704
  "version": "2.1.1",
1705
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1872,6 +1889,26 @@
1872
  }
1873
  }
1874
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1875
  "node_modules/deep-is": {
1876
  "version": "0.1.4",
1877
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1883,7 +1920,6 @@
1883
  "version": "1.1.4",
1884
  "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
1885
  "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
1886
- "dev": true,
1887
  "license": "MIT",
1888
  "dependencies": {
1889
  "es-define-property": "^1.0.0",
@@ -1901,7 +1937,6 @@
1901
  "version": "1.2.1",
1902
  "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
1903
  "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
1904
- "dev": true,
1905
  "license": "MIT",
1906
  "dependencies": {
1907
  "define-data-property": "^1.0.1",
@@ -2622,6 +2657,18 @@
2622
  "node": ">=0.10.0"
2623
  }
2624
  },
 
 
 
 
 
 
 
 
 
 
 
 
2625
  "node_modules/fast-deep-equal": {
2626
  "version": "3.1.3",
2627
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2629,6 +2676,12 @@
2629
  "dev": true,
2630
  "license": "MIT"
2631
  },
 
 
 
 
 
 
2632
  "node_modules/fast-glob": {
2633
  "version": "3.3.3",
2634
  "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2873,7 +2926,6 @@
2873
  "version": "1.2.3",
2874
  "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
2875
  "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
2876
- "dev": true,
2877
  "license": "MIT",
2878
  "funding": {
2879
  "url": "https://github.com/sponsors/ljharb"
@@ -3105,7 +3157,6 @@
3105
  "version": "1.0.2",
3106
  "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
3107
  "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
3108
- "dev": true,
3109
  "license": "MIT",
3110
  "dependencies": {
3111
  "es-define-property": "^1.0.0"
@@ -3240,6 +3291,22 @@
3240
  "node": ">= 0.4"
3241
  }
3242
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3243
  "node_modules/is-array-buffer": {
3244
  "version": "3.0.5",
3245
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -3385,7 +3452,6 @@
3385
  "version": "1.1.0",
3386
  "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
3387
  "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
3388
- "dev": true,
3389
  "license": "MIT",
3390
  "dependencies": {
3391
  "call-bound": "^1.0.2",
@@ -3534,7 +3600,6 @@
3534
  "version": "1.2.1",
3535
  "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
3536
  "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
3537
- "dev": true,
3538
  "license": "MIT",
3539
  "dependencies": {
3540
  "call-bound": "^1.0.2",
@@ -3886,6 +3951,12 @@
3886
  "url": "https://github.com/sponsors/sindresorhus"
3887
  }
3888
  },
 
 
 
 
 
 
3889
  "node_modules/lodash.merge": {
3890
  "version": "4.6.2",
3891
  "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4218,11 +4289,26 @@
4218
  "url": "https://github.com/sponsors/ljharb"
4219
  }
4220
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4221
  "node_modules/object-keys": {
4222
  "version": "1.1.1",
4223
  "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
4224
  "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
4225
- "dev": true,
4226
  "license": "MIT",
4227
  "engines": {
4228
  "node": ">= 0.4"
@@ -4396,6 +4482,12 @@
4396
  "url": "https://github.com/sponsors/sindresorhus"
4397
  }
4398
  },
 
 
 
 
 
 
4399
  "node_modules/parent-module": {
4400
  "version": "1.0.1",
4401
  "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4735,6 +4827,34 @@
4735
  ],
4736
  "license": "MIT"
4737
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4738
  "node_modules/react": {
4739
  "version": "18.3.1",
4740
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -4769,6 +4889,21 @@
4769
  "dev": true,
4770
  "license": "MIT"
4771
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4772
  "node_modules/read-cache": {
4773
  "version": "1.0.0",
4774
  "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4819,7 +4954,6 @@
4819
  "version": "1.5.4",
4820
  "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
4821
  "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
4822
- "dev": true,
4823
  "license": "MIT",
4824
  "dependencies": {
4825
  "call-bind": "^1.0.8",
@@ -5032,7 +5166,6 @@
5032
  "version": "1.2.2",
5033
  "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
5034
  "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
5035
- "dev": true,
5036
  "license": "MIT",
5037
  "dependencies": {
5038
  "define-data-property": "^1.1.4",
@@ -5050,7 +5183,6 @@
5050
  "version": "2.0.2",
5051
  "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
5052
  "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
5053
- "dev": true,
5054
  "license": "MIT",
5055
  "dependencies": {
5056
  "define-data-property": "^1.1.4",
 
15
  "next": "14.2.35",
16
  "react": "^18",
17
  "react-dom": "^18",
18
+ "react-quill": "^2.0.0",
19
  "tailwind-merge": "^3.5.0",
20
  "zustand": "^5.0.12"
21
  },
 
561
  "devOptional": true,
562
  "license": "MIT"
563
  },
564
+ "node_modules/@types/quill": {
565
+ "version": "1.3.10",
566
+ "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
567
+ "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
568
+ "license": "MIT",
569
+ "dependencies": {
570
+ "parchment": "^1.1.2"
571
+ }
572
+ },
573
  "node_modules/@types/react": {
574
  "version": "18.3.28",
575
  "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
 
1552
  "version": "1.0.8",
1553
  "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
1554
  "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
 
1555
  "license": "MIT",
1556
  "dependencies": {
1557
  "call-bind-apply-helpers": "^1.0.0",
 
1583
  "version": "1.0.4",
1584
  "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
1585
  "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
 
1586
  "license": "MIT",
1587
  "dependencies": {
1588
  "call-bind-apply-helpers": "^1.0.2",
 
1708
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
1709
  "license": "MIT"
1710
  },
1711
+ "node_modules/clone": {
1712
+ "version": "2.1.2",
1713
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
1714
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
1715
+ "license": "MIT",
1716
+ "engines": {
1717
+ "node": ">=0.8"
1718
+ }
1719
+ },
1720
  "node_modules/clsx": {
1721
  "version": "2.1.1",
1722
  "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
 
1889
  }
1890
  }
1891
  },
1892
+ "node_modules/deep-equal": {
1893
+ "version": "1.1.2",
1894
+ "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
1895
+ "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
1896
+ "license": "MIT",
1897
+ "dependencies": {
1898
+ "is-arguments": "^1.1.1",
1899
+ "is-date-object": "^1.0.5",
1900
+ "is-regex": "^1.1.4",
1901
+ "object-is": "^1.1.5",
1902
+ "object-keys": "^1.1.1",
1903
+ "regexp.prototype.flags": "^1.5.1"
1904
+ },
1905
+ "engines": {
1906
+ "node": ">= 0.4"
1907
+ },
1908
+ "funding": {
1909
+ "url": "https://github.com/sponsors/ljharb"
1910
+ }
1911
+ },
1912
  "node_modules/deep-is": {
1913
  "version": "0.1.4",
1914
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 
1920
  "version": "1.1.4",
1921
  "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
1922
  "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
 
1923
  "license": "MIT",
1924
  "dependencies": {
1925
  "es-define-property": "^1.0.0",
 
1937
  "version": "1.2.1",
1938
  "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
1939
  "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
 
1940
  "license": "MIT",
1941
  "dependencies": {
1942
  "define-data-property": "^1.0.1",
 
2657
  "node": ">=0.10.0"
2658
  }
2659
  },
2660
+ "node_modules/eventemitter3": {
2661
+ "version": "2.0.3",
2662
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
2663
+ "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
2664
+ "license": "MIT"
2665
+ },
2666
+ "node_modules/extend": {
2667
+ "version": "3.0.2",
2668
+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
2669
+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
2670
+ "license": "MIT"
2671
+ },
2672
  "node_modules/fast-deep-equal": {
2673
  "version": "3.1.3",
2674
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
2676
  "dev": true,
2677
  "license": "MIT"
2678
  },
2679
+ "node_modules/fast-diff": {
2680
+ "version": "1.1.2",
2681
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
2682
+ "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
2683
+ "license": "Apache-2.0"
2684
+ },
2685
  "node_modules/fast-glob": {
2686
  "version": "3.3.3",
2687
  "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
 
2926
  "version": "1.2.3",
2927
  "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
2928
  "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
 
2929
  "license": "MIT",
2930
  "funding": {
2931
  "url": "https://github.com/sponsors/ljharb"
 
3157
  "version": "1.0.2",
3158
  "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
3159
  "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
 
3160
  "license": "MIT",
3161
  "dependencies": {
3162
  "es-define-property": "^1.0.0"
 
3291
  "node": ">= 0.4"
3292
  }
3293
  },
3294
+ "node_modules/is-arguments": {
3295
+ "version": "1.2.0",
3296
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
3297
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
3298
+ "license": "MIT",
3299
+ "dependencies": {
3300
+ "call-bound": "^1.0.2",
3301
+ "has-tostringtag": "^1.0.2"
3302
+ },
3303
+ "engines": {
3304
+ "node": ">= 0.4"
3305
+ },
3306
+ "funding": {
3307
+ "url": "https://github.com/sponsors/ljharb"
3308
+ }
3309
+ },
3310
  "node_modules/is-array-buffer": {
3311
  "version": "3.0.5",
3312
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
 
3452
  "version": "1.1.0",
3453
  "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
3454
  "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
 
3455
  "license": "MIT",
3456
  "dependencies": {
3457
  "call-bound": "^1.0.2",
 
3600
  "version": "1.2.1",
3601
  "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
3602
  "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
 
3603
  "license": "MIT",
3604
  "dependencies": {
3605
  "call-bound": "^1.0.2",
 
3951
  "url": "https://github.com/sponsors/sindresorhus"
3952
  }
3953
  },
3954
+ "node_modules/lodash": {
3955
+ "version": "4.17.23",
3956
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
3957
+ "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
3958
+ "license": "MIT"
3959
+ },
3960
  "node_modules/lodash.merge": {
3961
  "version": "4.6.2",
3962
  "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
 
4289
  "url": "https://github.com/sponsors/ljharb"
4290
  }
4291
  },
4292
+ "node_modules/object-is": {
4293
+ "version": "1.1.6",
4294
+ "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
4295
+ "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
4296
+ "license": "MIT",
4297
+ "dependencies": {
4298
+ "call-bind": "^1.0.7",
4299
+ "define-properties": "^1.2.1"
4300
+ },
4301
+ "engines": {
4302
+ "node": ">= 0.4"
4303
+ },
4304
+ "funding": {
4305
+ "url": "https://github.com/sponsors/ljharb"
4306
+ }
4307
+ },
4308
  "node_modules/object-keys": {
4309
  "version": "1.1.1",
4310
  "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
4311
  "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
 
4312
  "license": "MIT",
4313
  "engines": {
4314
  "node": ">= 0.4"
 
4482
  "url": "https://github.com/sponsors/sindresorhus"
4483
  }
4484
  },
4485
+ "node_modules/parchment": {
4486
+ "version": "1.1.4",
4487
+ "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
4488
+ "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
4489
+ "license": "BSD-3-Clause"
4490
+ },
4491
  "node_modules/parent-module": {
4492
  "version": "1.0.1",
4493
  "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
 
4827
  ],
4828
  "license": "MIT"
4829
  },
4830
+ "node_modules/quill": {
4831
+ "version": "1.3.7",
4832
+ "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
4833
+ "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
4834
+ "license": "BSD-3-Clause",
4835
+ "dependencies": {
4836
+ "clone": "^2.1.1",
4837
+ "deep-equal": "^1.0.1",
4838
+ "eventemitter3": "^2.0.3",
4839
+ "extend": "^3.0.2",
4840
+ "parchment": "^1.1.4",
4841
+ "quill-delta": "^3.6.2"
4842
+ }
4843
+ },
4844
+ "node_modules/quill-delta": {
4845
+ "version": "3.6.3",
4846
+ "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
4847
+ "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
4848
+ "license": "MIT",
4849
+ "dependencies": {
4850
+ "deep-equal": "^1.0.1",
4851
+ "extend": "^3.0.2",
4852
+ "fast-diff": "1.1.2"
4853
+ },
4854
+ "engines": {
4855
+ "node": ">=0.10"
4856
+ }
4857
+ },
4858
  "node_modules/react": {
4859
  "version": "18.3.1",
4860
  "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
 
4889
  "dev": true,
4890
  "license": "MIT"
4891
  },
4892
+ "node_modules/react-quill": {
4893
+ "version": "2.0.0",
4894
+ "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
4895
+ "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
4896
+ "license": "MIT",
4897
+ "dependencies": {
4898
+ "@types/quill": "^1.3.10",
4899
+ "lodash": "^4.17.4",
4900
+ "quill": "^1.3.7"
4901
+ },
4902
+ "peerDependencies": {
4903
+ "react": "^16 || ^17 || ^18",
4904
+ "react-dom": "^16 || ^17 || ^18"
4905
+ }
4906
+ },
4907
  "node_modules/read-cache": {
4908
  "version": "1.0.0",
4909
  "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
 
4954
  "version": "1.5.4",
4955
  "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
4956
  "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
 
4957
  "license": "MIT",
4958
  "dependencies": {
4959
  "call-bind": "^1.0.8",
 
5166
  "version": "1.2.2",
5167
  "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
5168
  "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
 
5169
  "license": "MIT",
5170
  "dependencies": {
5171
  "define-data-property": "^1.1.4",
 
5183
  "version": "2.0.2",
5184
  "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
5185
  "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
 
5186
  "license": "MIT",
5187
  "dependencies": {
5188
  "define-data-property": "^1.1.4",
frontend/package.json CHANGED
@@ -16,6 +16,7 @@
16
  "next": "14.2.35",
17
  "react": "^18",
18
  "react-dom": "^18",
 
19
  "tailwind-merge": "^3.5.0",
20
  "zustand": "^5.0.12"
21
  },
 
16
  "next": "14.2.35",
17
  "react": "^18",
18
  "react-dom": "^18",
19
+ "react-quill": "^2.0.0",
20
  "tailwind-merge": "^3.5.0",
21
  "zustand": "^5.0.12"
22
  },
frontend/src/app/admin/page.tsx CHANGED
@@ -4,7 +4,8 @@ import { useEffect, useState, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
  import { useAuthStore } from "@/lib/store";
7
- import { Plus, LayoutDashboard, ShoppingCart } from "lucide-react";
 
8
 
9
  interface Course {
10
  id: number;
@@ -14,6 +15,7 @@ interface Course {
14
  driveLink?: string;
15
  price: number;
16
  category?: string;
 
17
  }
18
 
19
  interface Order {
@@ -39,8 +41,10 @@ export default function AdminDashboard() {
39
  const [driveLink, setDriveLink] = useState('');
40
  const [price, setPrice] = useState('');
41
  const [category, setCategory] = useState('');
 
42
  const [showAddForm, setShowAddForm] = useState(false);
43
  const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
 
44
 
45
  const fetchData = useCallback(async () => {
46
  try {
@@ -73,7 +77,8 @@ export default function AdminDashboard() {
73
  coverImage,
74
  driveLink,
75
  price: Number(price),
76
- category
 
77
  };
78
 
79
  let res;
@@ -89,7 +94,7 @@ export default function AdminDashboard() {
89
  setEditingCourseId(null);
90
  fetchData();
91
  // Reset form
92
- setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory('');
93
  }
94
  } catch (err) {
95
  console.error(err);
@@ -105,9 +110,29 @@ export default function AdminDashboard() {
105
  setDriveLink(course.driveLink || '');
106
  setPrice(course.price ? course.price.toString() : '');
107
  setCategory(course.category || '');
 
108
  setShowAddForm(true);
109
  };
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  return (
112
  <div className="flex flex-col md:flex-row gap-8 min-h-[calc(100vh-12rem)]">
113
  {/* Sidebar */}
@@ -151,7 +176,7 @@ export default function AdminDashboard() {
151
  if (showAddForm) {
152
  setShowAddForm(false);
153
  setEditingCourseId(null);
154
- setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory('');
155
  } else {
156
  setShowAddForm(true);
157
  }
@@ -163,69 +188,126 @@ export default function AdminDashboard() {
163
  </div>
164
 
165
  {showAddForm && (
166
- <form onSubmit={handleSubmitCourse} className="bg-gray-50 p-6 rounded-xl mb-8 space-y-4">
167
- <div className="grid grid-cols-2 gap-4">
168
- <div>
169
- <label className="block text-sm font-medium text-gray-700 mb-1">标题</label>
170
- <input type="text" required value={title} onChange={e => setTitle(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
 
 
 
 
 
 
 
 
 
 
 
171
  </div>
172
- <div>
173
- <label className="block text-sm font-medium text-gray-700 mb-1">价格 (¥)</label>
174
- <input type="number" required value={price} onChange={e => setPrice(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
 
 
 
 
 
175
  </div>
176
- <div className="col-span-2">
177
- <label className="block text-sm font-medium text-gray-700 mb-1">描述</label>
178
- <textarea rows={3} value={description} onChange={e => setDescription(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200"></textarea>
 
 
 
179
  </div>
180
- <div className="col-span-2">
181
- <label className="block text-sm font-medium text-gray-700 mb-1">封面图片URL</label>
182
- <div className="flex gap-2">
183
- <input type="text" required value={coverImage} onChange={e => setCoverImage(e.target.value)} className="flex-1 px-4 py-2 rounded-lg border border-gray-200" placeholder="请输入图片URL或点击右侧上传" />
184
- <label className="cursor-pointer px-4 py-2 bg-gray-100 text-gray-700 rounded-lg border border-gray-200 hover:bg-gray-200 transition-colors whitespace-nowrap flex items-center justify-center">
185
- 上传图片
186
- <input
187
- type="file"
188
- accept="image/*"
189
- className="hidden"
190
- onChange={async (e) => {
191
- const file = e.target.files?.[0];
192
- if (!file) return;
193
- const formData = new FormData();
194
- formData.append('file', file);
195
- try {
196
- const res = await api.post('/api/upload', formData, {
197
- headers: {
198
- 'Content-Type': 'multipart/form-data'
199
- }
200
- });
201
- if (res.success) {
202
- setCoverImage(res.url);
203
- } else {
 
 
 
 
 
 
 
 
 
204
  alert('上传失败');
205
  }
206
- } catch (err) {
207
- console.error(err);
208
- alert('上传失败');
209
- }
210
- }}
211
- />
212
- </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  </div>
214
  </div>
215
- <div className="col-span-2">
216
- <label className="block text-sm font-medium text-gray-700 mb-1">分类</label>
217
- <input type="text" value={category} onChange={e => setCategory(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
218
- </div>
219
- <div className="col-span-2">
220
- <label className="block text-sm font-medium text-gray-700 mb-1">网盘链接 (受保护)</label>
221
- <input type="text" required value={driveLink} onChange={e => setDriveLink(e.target.value)} className="w-full px-4 py-2 rounded-lg border border-gray-200" />
 
222
  </div>
223
  </div>
224
- <div className="flex justify-end pt-4">
225
- <button type="submit" className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700">
226
- {editingCourseId ? '更新课程' : '保存课程'}
227
- </button>
228
- </div>
229
  </form>
230
  )}
231
 
@@ -236,6 +318,7 @@ export default function AdminDashboard() {
236
  <th className="pb-3 font-medium">ID</th>
237
  <th className="pb-3 font-medium">课程标题</th>
238
  <th className="pb-3 font-medium">价格</th>
 
239
  <th className="pb-3 font-medium">分类</th>
240
  <th className="pb-3 font-medium">操作</th>
241
  </tr>
@@ -246,9 +329,11 @@ export default function AdminDashboard() {
246
  <td className="py-4 text-gray-500">#{course.id}</td>
247
  <td className="py-4 font-medium text-gray-900">{course.title}</td>
248
  <td className="py-4">¥{course.price}</td>
 
249
  <td className="py-4"><span className="px-2 py-1 bg-gray-100 rounded-md text-xs">{course.category || 'N/A'}</span></td>
250
  <td className="py-4">
251
  <button onClick={() => handleEdit(course)} className="text-blue-600 hover:underline mr-3">编辑</button>
 
252
  </td>
253
  </tr>
254
  ))}
 
4
  import { useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
  import { useAuthStore } from "@/lib/store";
7
+ import { Plus, LayoutDashboard, ShoppingCart, ArrowLeft, Settings } from "lucide-react";
8
+ import RichTextEditor from "@/components/RichTextEditor";
9
 
10
  interface Course {
11
  id: number;
 
15
  driveLink?: string;
16
  price: number;
17
  category?: string;
18
+ viewCount?: number;
19
  }
20
 
21
  interface Order {
 
41
  const [driveLink, setDriveLink] = useState('');
42
  const [price, setPrice] = useState('');
43
  const [category, setCategory] = useState('');
44
+ const [viewCount, setViewCount] = useState<number | string>(0);
45
  const [showAddForm, setShowAddForm] = useState(false);
46
  const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
47
+ const [showSettings, setShowSettings] = useState(true);
48
 
49
  const fetchData = useCallback(async () => {
50
  try {
 
77
  coverImage,
78
  driveLink,
79
  price: Number(price),
80
+ category,
81
+ viewCount: Number(viewCount) || 0
82
  };
83
 
84
  let res;
 
94
  setEditingCourseId(null);
95
  fetchData();
96
  // Reset form
97
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0);
98
  }
99
  } catch (err) {
100
  console.error(err);
 
110
  setDriveLink(course.driveLink || '');
111
  setPrice(course.price ? course.price.toString() : '');
112
  setCategory(course.category || '');
113
+ setViewCount(course.viewCount || 0);
114
  setShowAddForm(true);
115
  };
116
 
117
+ const handleDelete = async (id: number) => {
118
+ if (!window.confirm('确定要删除该课程吗?删除后无法恢复。')) {
119
+ return;
120
+ }
121
+
122
+ try {
123
+ const res = await api.delete(`/api/admin/courses/${id}`);
124
+ if (res.success) {
125
+ alert('课程删除成功');
126
+ fetchData();
127
+ } else {
128
+ alert('删除失败');
129
+ }
130
+ } catch (err) {
131
+ console.error(err);
132
+ alert('删除失败');
133
+ }
134
+ };
135
+
136
  return (
137
  <div className="flex flex-col md:flex-row gap-8 min-h-[calc(100vh-12rem)]">
138
  {/* Sidebar */}
 
176
  if (showAddForm) {
177
  setShowAddForm(false);
178
  setEditingCourseId(null);
179
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0);
180
  } else {
181
  setShowAddForm(true);
182
  }
 
188
  </div>
189
 
190
  {showAddForm && (
191
+ <form onSubmit={handleSubmitCourse} className="bg-white border border-gray-200 rounded-xl mb-8 flex flex-col md:flex-row overflow-hidden shadow-sm min-h-[600px]">
192
+ {/* Main Editor Area */}
193
+ <div className="flex-1 flex flex-col min-w-0">
194
+ {/* Top Bar */}
195
+ <div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between bg-white">
196
+ <input
197
+ type="text"
198
+ required
199
+ value={title}
200
+ onChange={e => setTitle(e.target.value)}
201
+ placeholder="输入文章/课程标题..."
202
+ className="w-full text-3xl font-bold text-gray-900 border-none outline-none focus:ring-0 placeholder:text-gray-300"
203
+ />
204
+ <button type="button" onClick={() => setShowSettings(!showSettings)} className="ml-4 p-2 text-gray-500 hover:bg-gray-100 rounded-lg md:hidden">
205
+ <Settings className="w-5 h-5" />
206
+ </button>
207
  </div>
208
+
209
+ {/* Rich Text Editor */}
210
+ <div className="flex-1 flex flex-col [&_.quill]:flex-1 [&_.quill]:flex [&_.quill]:flex-col [&_.ql-container]:flex-1 [&_.ql-editor]:min-h-[400px]">
211
+ <RichTextEditor
212
+ value={description}
213
+ onChange={setDescription}
214
+ placeholder="从这里开始写正文..."
215
+ />
216
  </div>
217
+ </div>
218
+
219
+ {/* Sidebar Settings */}
220
+ <div className={`w-full md:w-80 bg-gray-50 border-l border-gray-200 flex flex-col ${showSettings ? 'block' : 'hidden md:flex'}`}>
221
+ <div className="p-4 border-b border-gray-200 font-medium text-gray-900 flex justify-between items-center bg-white">
222
+ 文章设置
223
  </div>
224
+
225
+ <div className="flex-1 overflow-y-auto p-5 space-y-6">
226
+ {/* Cover Image */}
227
+ <div>
228
+ <label className="block text-sm font-medium text-gray-700 mb-2">封面图片</label>
229
+ {coverImage ? (
230
+ <div className="relative w-full aspect-video rounded-lg overflow-hidden border border-gray-200 mb-2">
231
+ <img src={coverImage} alt="Cover" className="w-full h-full object-cover" />
232
+ <button type="button" onClick={() => setCoverImage('')} className="absolute top-2 right-2 bg-black/50 text-white rounded-full p-1 hover:bg-black/70">
233
+ &times;
234
+ </button>
235
+ </div>
236
+ ) : (
237
+ <div className="w-full aspect-video bg-white border border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 cursor-pointer transition-colors relative mb-2">
238
+ <Plus className="w-6 h-6 mb-1 text-gray-400" />
239
+ <span className="text-sm">上传封面</span>
240
+ <input
241
+ type="file"
242
+ accept="image/*"
243
+ className="absolute inset-0 opacity-0 cursor-pointer"
244
+ onChange={async (e) => {
245
+ const file = e.target.files?.[0];
246
+ if (!file) return;
247
+ const formData = new FormData();
248
+ formData.append('file', file);
249
+ try {
250
+ const res = await api.post('/api/upload', formData, {
251
+ headers: { 'Content-Type': 'multipart/form-data' }
252
+ });
253
+ if (res.success) setCoverImage(res.url);
254
+ else alert('上传失败');
255
+ } catch (err) {
256
+ console.error(err);
257
  alert('上传失败');
258
  }
259
+ }}
260
+ />
261
+ </div>
262
+ )}
263
+ <input type="text" value={coverImage} onChange={e => setCoverImage(e.target.value)} placeholder="或输入图片URL" className="w-full px-3 py-2 text-sm rounded-lg border border-gray-200 bg-white" />
264
+ </div>
265
+
266
+ {/* Price */}
267
+ <div>
268
+ <label className="block text-sm font-medium text-gray-700 mb-2">价格 (¥)</label>
269
+ <input type="number" required value={price} onChange={e => setPrice(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0.00" />
270
+ </div>
271
+
272
+ {/* View Count */}
273
+ <div>
274
+ <label className="block text-sm font-medium text-gray-700 mb-2">浏览人数设置</label>
275
+ <input type="number" value={viewCount} onChange={e => setViewCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
276
+ </div>
277
+
278
+ {/* Category */}
279
+ <div>
280
+ <label className="block text-sm font-medium text-gray-700 mb-2">分类</label>
281
+ <select
282
+ value={category}
283
+ onChange={e => setCategory(e.target.value)}
284
+ className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white"
285
+ >
286
+ <option value="">请选择分类</option>
287
+ <option value="AI新资讯">AI新资讯</option>
288
+ <option value="Comfyui资讯">Comfyui资讯</option>
289
+ <option value="满血整合包">满血整合包</option>
290
+ <option value="闭源API接口">闭源API接口</option>
291
+ <option value="应用广场">应用广场</option>
292
+ </select>
293
+ </div>
294
+
295
+ {/* Drive Link */}
296
+ <div>
297
+ <label className="block text-sm font-medium text-gray-700 mb-2">付费资料链接 (网盘)</label>
298
+ <textarea required value={driveLink} onChange={e => setDriveLink(e.target.value)} rows={3} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm" placeholder="输入百度网盘、阿里云盘等链接及提取码..."></textarea>
299
  </div>
300
  </div>
301
+
302
+ <div className="p-4 border-t border-gray-200 bg-white flex gap-3">
303
+ <button type="button" onClick={() => setShowAddForm(false)} className="flex-1 px-4 py-2 border border-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors">
304
+ 取消
305
+ </button>
306
+ <button type="submit" className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors shadow-sm">
307
+ {editingCourseId ? '更新发布' : '立即发布'}
308
+ </button>
309
  </div>
310
  </div>
 
 
 
 
 
311
  </form>
312
  )}
313
 
 
318
  <th className="pb-3 font-medium">ID</th>
319
  <th className="pb-3 font-medium">课程标题</th>
320
  <th className="pb-3 font-medium">价格</th>
321
+ <th className="pb-3 font-medium">浏览人���</th>
322
  <th className="pb-3 font-medium">分类</th>
323
  <th className="pb-3 font-medium">操作</th>
324
  </tr>
 
329
  <td className="py-4 text-gray-500">#{course.id}</td>
330
  <td className="py-4 font-medium text-gray-900">{course.title}</td>
331
  <td className="py-4">¥{course.price}</td>
332
+ <td className="py-4 text-gray-500">{course.viewCount || 0}</td>
333
  <td className="py-4"><span className="px-2 py-1 bg-gray-100 rounded-md text-xs">{course.category || 'N/A'}</span></td>
334
  <td className="py-4">
335
  <button onClick={() => handleEdit(course)} className="text-blue-600 hover:underline mr-3">编辑</button>
336
+ <button onClick={() => handleDelete(course.id)} className="text-red-600 hover:underline">删除</button>
337
  </td>
338
  </tr>
339
  ))}
frontend/src/app/client-layout.tsx CHANGED
@@ -17,13 +17,23 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
17
  <>
18
  <header className="bg-white border-b sticky top-0 z-50">
19
  <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
20
- <Link href="/" className="text-xl font-bold text-blue-600">课程中心</Link>
21
  <nav className="flex items-center gap-6">
22
- <Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">发现课程</Link>
 
 
 
 
 
23
 
24
  {user ? (
25
  <>
26
- <Link href="/user/courses" className="text-gray-600 hover:text-blue-600 font-medium">我的学习</Link>
 
 
 
 
 
27
  {user.role === 'admin' && (
28
  <Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
29
  )}
@@ -52,7 +62,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
52
 
53
  <footer className="bg-white border-t py-8 mt-auto">
54
  <div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
55
- © 2024 课程中心. 保留所有权利。
56
  </div>
57
  </footer>
58
  </>
 
17
  <>
18
  <header className="bg-white border-b sticky top-0 z-50">
19
  <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
20
+ <Link href="/" className="text-xl font-bold text-blue-600">极简AI</Link>
21
  <nav className="flex items-center gap-6">
22
+ <Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">首页</Link>
23
+ <Link href="/?category=ai-news" className="text-gray-600 hover:text-blue-600 font-medium">AI新资讯</Link>
24
+ <Link href="/?category=comfyui" className="text-gray-600 hover:text-blue-600 font-medium">Comfyui资讯</Link>
25
+ <Link href="/?category=full-blood" className="text-gray-600 hover:text-blue-600 font-medium">满血整合包</Link>
26
+ <Link href="/?category=closed-api" className="text-gray-600 hover:text-blue-600 font-medium">闭源API接口</Link>
27
+ <Link href="/?category=app-square" className="text-gray-600 hover:text-blue-600 font-medium">应用广场</Link>
28
 
29
  {user ? (
30
  <>
31
+ {user.role !== 'admin' && (
32
+ <>
33
+ <Link href="/user/stars" className="text-gray-600 hover:text-blue-600 font-medium">我的收藏</Link>
34
+ <Link href="/user/courses" className="text-gray-600 hover:text-blue-600 font-medium">我的学习</Link>
35
+ </>
36
+ )}
37
  {user.role === 'admin' && (
38
  <Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
39
  )}
 
62
 
63
  <footer className="bg-white border-t py-8 mt-auto">
64
  <div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
65
+ © {new Date().getFullYear()} 极简AI. 保留所有权利。
66
  </div>
67
  </footer>
68
  </>
frontend/src/app/course/[id]/page.tsx CHANGED
@@ -1,9 +1,9 @@
1
  "use client";
2
 
3
- import { useEffect, useState } from "react";
4
  import { useParams, useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
- import { ArrowLeft, Link as LinkIcon, Copy, X } from "lucide-react";
7
  import Link from "next/link";
8
  import { useAuthStore } from "@/lib/store";
9
 
@@ -17,6 +17,9 @@ export default function CourseDetail() {
17
  const [loading, setLoading] = useState(true);
18
  const [hasPurchased, setHasPurchased] = useState(false);
19
  const [selectedLink, setSelectedLink] = useState<string | null>(null);
 
 
 
20
 
21
  useEffect(() => {
22
  const fetchData = async () => {
@@ -24,16 +27,31 @@ export default function CourseDetail() {
24
  const res = await api.get(`/api/courses/${id}`);
25
  if (res.success) {
26
  setCourse(res.data);
 
 
 
 
 
27
  }
28
 
29
- // 检查购买状态
30
  if (token) {
31
- const myCoursesRes = await api.get('/api/courses/my');
 
 
 
 
32
  if (myCoursesRes.success) {
33
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
  const isPurchased = myCoursesRes.data.some((c: any) => c.id === Number(id));
35
  setHasPurchased(isPurchased);
36
  }
 
 
 
 
 
 
37
  }
38
  } catch (err) {
39
  console.error(err);
@@ -89,6 +107,34 @@ export default function CourseDetail() {
89
  }
90
  };
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  if (loading) {
93
  return <div className="animate-pulse h-96 bg-white rounded-xl"></div>;
94
  }
@@ -116,8 +162,14 @@ export default function CourseDetail() {
116
  <div className="p-8">
117
  <div className="flex items-start justify-between mb-6">
118
  <div>
119
- <div className="inline-block px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm font-medium mb-3">
120
- {course.category || '通用'}
 
 
 
 
 
 
121
  </div>
122
  <h1 className="text-3xl font-bold text-gray-900">{course.title}</h1>
123
  </div>
@@ -128,34 +180,58 @@ export default function CourseDetail() {
128
 
129
  <div className="prose max-w-none text-gray-600 mb-8">
130
  <h3 className="text-xl font-semibold text-gray-900 mb-4">关于本课程</h3>
131
- <p className="whitespace-pre-line">{course.description}</p>
 
 
 
132
  </div>
133
 
134
- <div className="flex justify-end pt-6 border-t gap-4">
135
- {hasPurchased ? (
136
- <>
137
- <button
138
- disabled
139
- className="bg-gray-100 text-gray-500 px-8 py-4 rounded-xl font-semibold text-lg cursor-not-allowed"
140
- >
141
- 已购买
142
- </button>
143
- <button
144
- onClick={handleAccess}
145
- className="bg-blue-600 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200 flex items-center"
146
- >
147
- <LinkIcon className="w-5 h-5 mr-2" />
148
- 获取资料
149
- </button>
150
- </>
151
- ) : (
152
  <button
153
- onClick={handleBuy}
154
- className="bg-blue-600 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
155
  >
156
- 立即购买
 
157
  </button>
158
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  </div>
160
  </div>
161
  </div>
 
1
  "use client";
2
 
3
+ import { useEffect, useState, useRef } from "react";
4
  import { useParams, useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
+ import { ArrowLeft, Link as LinkIcon, Copy, X, Eye, ThumbsUp, Star } from "lucide-react";
7
  import Link from "next/link";
8
  import { useAuthStore } from "@/lib/store";
9
 
 
17
  const [loading, setLoading] = useState(true);
18
  const [hasPurchased, setHasPurchased] = useState(false);
19
  const [selectedLink, setSelectedLink] = useState<string | null>(null);
20
+ const [isLiked, setIsLiked] = useState(false);
21
+ const [isStarred, setIsStarred] = useState(false);
22
+ const hasViewedRef = useRef(false);
23
 
24
  useEffect(() => {
25
  const fetchData = async () => {
 
27
  const res = await api.get(`/api/courses/${id}`);
28
  if (res.success) {
29
  setCourse(res.data);
30
+ // Increment view count in the background only once per mount
31
+ if (!hasViewedRef.current) {
32
+ hasViewedRef.current = true;
33
+ api.post(`/api/courses/${id}/view`, {}).catch(console.error);
34
+ }
35
  }
36
 
37
+ // 检查购买状态和收藏状态
38
  if (token) {
39
+ const [myCoursesRes, myStarsRes] = await Promise.all([
40
+ api.get('/api/courses/my'),
41
+ api.get('/api/courses/my-stars')
42
+ ]);
43
+
44
  if (myCoursesRes.success) {
45
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
  const isPurchased = myCoursesRes.data.some((c: any) => c.id === Number(id));
47
  setHasPurchased(isPurchased);
48
  }
49
+
50
+ if (myStarsRes.success) {
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ const isStarred = myStarsRes.data.some((c: any) => c.id === Number(id));
53
+ setIsStarred(isStarred);
54
+ }
55
  }
56
  } catch (err) {
57
  console.error(err);
 
107
  }
108
  };
109
 
110
+ const handleToggleLike = async () => {
111
+ try {
112
+ const res = await api.post(`/api/courses/${id}/like`, {});
113
+ if (res.success) {
114
+ setIsLiked(!isLiked);
115
+ // Refresh course data to get updated count
116
+ const courseRes = await api.get(`/api/courses/${id}`);
117
+ if (courseRes.success) setCourse(courseRes.data);
118
+ }
119
+ } catch (err) {
120
+ console.error(err);
121
+ }
122
+ };
123
+
124
+ const handleToggleStar = async () => {
125
+ try {
126
+ const res = await api.post(`/api/courses/${id}/star`, {});
127
+ if (res.success) {
128
+ setIsStarred(!isStarred);
129
+ // Refresh course data to get updated count
130
+ const courseRes = await api.get(`/api/courses/${id}`);
131
+ if (courseRes.success) setCourse(courseRes.data);
132
+ }
133
+ } catch (err) {
134
+ console.error(err);
135
+ }
136
+ };
137
+
138
  if (loading) {
139
  return <div className="animate-pulse h-96 bg-white rounded-xl"></div>;
140
  }
 
162
  <div className="p-8">
163
  <div className="flex items-start justify-between mb-6">
164
  <div>
165
+ <div className="flex items-center gap-3 mb-3">
166
+ <div className="inline-block px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm font-medium">
167
+ {course.category || '通用'}
168
+ </div>
169
+ <div className="flex items-center text-gray-500 text-sm">
170
+ <Eye className="w-4 h-4 mr-1" />
171
+ <span>{course.viewCount || 0} 人看过</span>
172
+ </div>
173
  </div>
174
  <h1 className="text-3xl font-bold text-gray-900">{course.title}</h1>
175
  </div>
 
180
 
181
  <div className="prose max-w-none text-gray-600 mb-8">
182
  <h3 className="text-xl font-semibold text-gray-900 mb-4">关于本课程</h3>
183
+ <div
184
+ className="[&>p]:mb-4 [&>img]:max-w-full [&>img]:h-auto [&>img]:rounded-lg [&_a]:text-blue-600 [&_a]:underline"
185
+ dangerouslySetInnerHTML={{ __html: course.description || '' }}
186
+ />
187
  </div>
188
 
189
+ <div className="flex justify-between items-center pt-6 border-t">
190
+ {/* Interaction Buttons */}
191
+ <div className="flex gap-6">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  <button
193
+ onClick={handleToggleLike}
194
+ className={`flex items-center gap-2 transition-colors ${isLiked ? 'text-blue-600' : 'text-gray-500 hover:text-gray-900'}`}
195
  >
196
+ <ThumbsUp className={`w-6 h-6 ${isLiked ? 'fill-current' : ''}`} />
197
+ <span className="font-medium">{course.likeCount || 0}</span>
198
  </button>
199
+ <button
200
+ onClick={handleToggleStar}
201
+ className={`flex items-center gap-2 transition-colors ${isStarred ? 'text-yellow-500' : 'text-gray-500 hover:text-gray-900'}`}
202
+ >
203
+ <Star className={`w-6 h-6 ${isStarred ? 'fill-current' : ''}`} />
204
+ <span className="font-medium">{course.starCount || 0}</span>
205
+ </button>
206
+ </div>
207
+
208
+ {/* Action Buttons */}
209
+ <div className="flex gap-4">
210
+ {hasPurchased ? (
211
+ <>
212
+ <button
213
+ disabled
214
+ className="bg-gray-100 text-gray-500 px-8 py-2.5 rounded-xl font-semibold text-base cursor-not-allowed"
215
+ >
216
+ 已购买
217
+ </button>
218
+ <button
219
+ onClick={handleAccess}
220
+ className="bg-blue-600 text-white px-8 py-2.5 rounded-xl font-semibold text-base hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200 flex items-center"
221
+ >
222
+ <LinkIcon className="w-5 h-5 mr-2" />
223
+ 获取资料
224
+ </button>
225
+ </>
226
+ ) : (
227
+ <button
228
+ onClick={handleBuy}
229
+ className="bg-blue-600 text-white px-8 py-2.5 rounded-xl font-semibold text-base hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200"
230
+ >
231
+ 立即购买
232
+ </button>
233
+ )}
234
+ </div>
235
  </div>
236
  </div>
237
  </div>
frontend/src/app/login/page.tsx CHANGED
@@ -9,9 +9,9 @@ export default function Login() {
9
  const router = useRouter();
10
  const { setAuth } = useAuthStore();
11
  const [isLogin, setIsLogin] = useState(true);
12
- const [phone, setPhone] = useState("");
13
  const [password, setPassword] = useState("");
14
- const [smsCode, setSmsCode] = useState("");
15
  const [loading, setLoading] = useState(false);
16
 
17
  const handleSubmit = async (e: React.FormEvent) => {
@@ -20,19 +20,19 @@ export default function Login() {
20
 
21
  try {
22
  if (isLogin) {
23
- const res = await api.post('/api/auth/login', { phone, password });
24
  if (res.success) {
25
  setAuth(
26
- { id: res.data.userId, phone, nickname: res.data.nickname, role: res.data.role },
27
  res.data.token
28
  );
29
  router.push('/');
30
  }
31
  } else {
32
- const res = await api.post('/api/auth/register', { phone, password, smsCode: smsCode || '123456' });
33
  if (res.success) {
34
  setAuth(
35
- { id: res.data.userId, phone, nickname: res.data.nickname, role: res.data.role },
36
  res.data.token
37
  );
38
  router.push('/');
@@ -56,14 +56,14 @@ export default function Login() {
56
 
57
  <form onSubmit={handleSubmit} className="space-y-5">
58
  <div>
59
- <label className="block text-sm font-medium text-gray-700 mb-1">手机号码</label>
60
  <input
61
- type="text"
62
  required
63
- value={phone}
64
- onChange={(e) => setPhone(e.target.value)}
65
  className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
66
- placeholder="请输入手机号码"
67
  />
68
  </div>
69
 
@@ -81,13 +81,13 @@ export default function Login() {
81
 
82
  {!isLogin && (
83
  <div>
84
- <label className="block text-sm font-medium text-gray-700 mb-1">短信验证码 (模拟: 123456)</label>
85
  <div className="flex gap-3">
86
  <input
87
  type="text"
88
  required
89
- value={smsCode}
90
- onChange={(e) => setSmsCode(e.target.value)}
91
  className="flex-1 px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
92
  placeholder="6位验证码"
93
  />
 
9
  const router = useRouter();
10
  const { setAuth } = useAuthStore();
11
  const [isLogin, setIsLogin] = useState(true);
12
+ const [email, setEmail] = useState("");
13
  const [password, setPassword] = useState("");
14
+ const [emailCode, setEmailCode] = useState("");
15
  const [loading, setLoading] = useState(false);
16
 
17
  const handleSubmit = async (e: React.FormEvent) => {
 
20
 
21
  try {
22
  if (isLogin) {
23
+ const res = await api.post('/api/auth/login', { email, password });
24
  if (res.success) {
25
  setAuth(
26
+ { id: res.data.userId, email, nickname: res.data.nickname, role: res.data.role },
27
  res.data.token
28
  );
29
  router.push('/');
30
  }
31
  } else {
32
+ const res = await api.post('/api/auth/register', { email, password, emailCode: emailCode || '123456' });
33
  if (res.success) {
34
  setAuth(
35
+ { id: res.data.userId, email, nickname: res.data.nickname, role: res.data.role },
36
  res.data.token
37
  );
38
  router.push('/');
 
56
 
57
  <form onSubmit={handleSubmit} className="space-y-5">
58
  <div>
59
+ <label className="block text-sm font-medium text-gray-700 mb-1">邮箱地址</label>
60
  <input
61
+ type="email"
62
  required
63
+ value={email}
64
+ onChange={(e) => setEmail(e.target.value)}
65
  className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
66
+ placeholder="请输入邮箱地址"
67
  />
68
  </div>
69
 
 
81
 
82
  {!isLogin && (
83
  <div>
84
+ <label className="block text-sm font-medium text-gray-700 mb-1">邮箱验证码 (模拟: 123456)</label>
85
  <div className="flex gap-3">
86
  <input
87
  type="text"
88
  required
89
+ value={emailCode}
90
+ onChange={(e) => setEmailCode(e.target.value)}
91
  className="flex-1 px-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all"
92
  placeholder="6位验证码"
93
  />
frontend/src/app/page.tsx CHANGED
@@ -1,20 +1,39 @@
1
  "use client";
2
 
3
- import { useEffect, useState } from "react";
4
  import { api } from "@/lib/api";
5
  import Link from "next/link";
6
- import { BookOpen, Search } from "lucide-react";
 
7
 
8
- export default function Home() {
 
 
 
 
 
 
 
 
9
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
  const [courses, setCourses] = useState<any[]>([]);
11
  const [loading, setLoading] = useState(true);
12
  const [searchQuery, setSearchQuery] = useState("");
 
 
13
 
14
- const filteredCourses = courses.filter(course =>
15
- course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
16
- course.description?.toLowerCase().includes(searchQuery.toLowerCase())
17
- );
 
 
 
 
 
 
 
 
18
 
19
  useEffect(() => {
20
  const fetchCourses = async () => {
@@ -33,6 +52,10 @@ export default function Home() {
33
  fetchCourses();
34
  }, []);
35
 
 
 
 
 
36
  return (
37
  <div className="space-y-8">
38
  {/* Search Section */}
@@ -50,54 +73,92 @@ export default function Home() {
50
  </section>
51
 
52
  {/* Course List */}
53
- <section>
54
- <div className="flex items-center justify-between mb-6">
55
- <h2 className="text-2xl font-bold text-gray-900">
56
- {searchQuery ? '搜索结果' : '精选课程'}
57
- </h2>
58
- </div>
59
-
60
- {loading ? (
61
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
62
  {[1, 2, 3].map((i) => (
63
  <div key={i} className="bg-white rounded-xl h-80 animate-pulse"></div>
64
  ))}
65
  </div>
66
- ) : filteredCourses.length > 0 ? (
 
 
 
 
 
 
 
67
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
68
  {filteredCourses.map((course) => (
69
- <Link href={`/course/${course.id}`} key={course.id}>
70
- <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow group">
71
- <div className="aspect-video w-full bg-gray-200 relative overflow-hidden">
72
- {/* eslint-disable-next-line @next/next/no-img-element */}
73
- <img
74
- src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design%20with%20books%20and%20laptop%20clean%20style&image_size=landscape_16_9"}
75
- alt={course.title}
76
- className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
77
- />
78
- </div>
79
- <div className="p-5">
80
- <div className="text-xs font-medium text-blue-600 mb-2">{course.category || '通用'}</div>
81
- <h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">{course.title}</h3>
82
- <p className="text-gray-500 text-sm mb-4 line-clamp-2">{course.description}</p>
83
- <div className="flex items-center justify-between mt-auto">
84
- <span className="text-xl font-bold text-gray-900">¥{course.price}</span>
85
- <span className="text-sm text-blue-600 font-medium">了解详情 →</span>
86
- </div>
87
- </div>
88
- </div>
89
- </Link>
90
  ))}
91
  </div>
92
- ) : (
 
 
93
  <div className="text-center py-12 bg-white rounded-xl border border-gray-100">
94
  <BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
95
  <p className="text-gray-500">
96
- {searchQuery ? '未找到匹配的课程' : '暂无可用课程'}
97
  </p>
98
  </div>
99
- )}
100
- </section>
101
  </div>
102
  );
103
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  "use client";
2
 
3
+ import { useEffect, useState, Suspense } from "react";
4
  import { api } from "@/lib/api";
5
  import Link from "next/link";
6
+ import { BookOpen, Search, Eye, ThumbsUp, Star } from "lucide-react";
7
+ import { useSearchParams } from "next/navigation";
8
 
9
+ const CATEGORY_MAP: Record<string, string> = {
10
+ 'ai-news': 'AI新资讯',
11
+ 'comfyui': 'Comfyui资讯',
12
+ 'full-blood': '满血整合包',
13
+ 'closed-api': '闭源API接口',
14
+ 'app-square': '应用广场'
15
+ };
16
+
17
+ function HomeContent() {
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
  const [courses, setCourses] = useState<any[]>([]);
20
  const [loading, setLoading] = useState(true);
21
  const [searchQuery, setSearchQuery] = useState("");
22
+ const searchParams = useSearchParams();
23
+ const currentCategory = searchParams.get('category');
24
 
25
+ const filteredCourses = courses.filter(course => {
26
+ const matchesSearch = course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
27
+ course.description?.toLowerCase().includes(searchQuery.toLowerCase());
28
+
29
+ if (currentCategory && CATEGORY_MAP[currentCategory]) {
30
+ // 假设后端的分类数据和我们的名称匹配,或者为了演示只显示包含该关键词的课程
31
+ // 如果后端没有这些数据,我们就暂且根据标题或者描述来做简单匹配过滤,或者假设它们有一个 category 字段
32
+ const categoryName = CATEGORY_MAP[currentCategory];
33
+ return matchesSearch && (course.category === categoryName || course.title.includes(categoryName) || !course.category);
34
+ }
35
+ return matchesSearch;
36
+ });
37
 
38
  useEffect(() => {
39
  const fetchCourses = async () => {
 
52
  fetchCourses();
53
  }, []);
54
 
55
+ const displayTitle = currentCategory && CATEGORY_MAP[currentCategory]
56
+ ? CATEGORY_MAP[currentCategory]
57
+ : '精选课程';
58
+
59
  return (
60
  <div className="space-y-8">
61
  {/* Search Section */}
 
73
  </section>
74
 
75
  {/* Course List */}
76
+ {loading ? (
77
+ <section>
78
+ <div className="flex items-center justify-between mb-6">
79
+ <h2 className="text-2xl font-bold text-gray-900">加载中...</h2>
80
+ </div>
 
 
 
81
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
82
  {[1, 2, 3].map((i) => (
83
  <div key={i} className="bg-white rounded-xl h-80 animate-pulse"></div>
84
  ))}
85
  </div>
86
+ </section>
87
+ ) : filteredCourses.length > 0 ? (
88
+ <section>
89
+ <div className="flex items-center justify-between mb-6">
90
+ <h2 className="text-2xl font-bold text-gray-900">
91
+ {searchQuery ? `搜索结果 - ${displayTitle}` : displayTitle}
92
+ </h2>
93
+ </div>
94
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
95
  {filteredCourses.map((course) => (
96
+ <CourseCard key={course.id} course={course} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  ))}
98
  </div>
99
+ </section>
100
+ ) : (
101
+ <section>
102
  <div className="text-center py-12 bg-white rounded-xl border border-gray-100">
103
  <BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
104
  <p className="text-gray-500">
105
+ {searchQuery ? '未找到匹配的课程' : `暂无 ${currentCategory && CATEGORY_MAP[currentCategory] ? CATEGORY_MAP[currentCategory] : '可用'} 课程`}
106
  </p>
107
  </div>
108
+ </section>
109
+ )}
110
  </div>
111
  );
112
  }
113
+
114
+ export default function Home() {
115
+ return (
116
+ <Suspense fallback={<div className="p-8 text-center text-gray-500">加载中...</div>}>
117
+ <HomeContent />
118
+ </Suspense>
119
+ );
120
+ }
121
+
122
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
123
+ function CourseCard({ course }: { course: any }) {
124
+ return (
125
+ <Link href={`/course/${course.id}`}>
126
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow group flex flex-col h-full">
127
+ <div className="aspect-video w-full bg-gray-200 relative overflow-hidden">
128
+ {/* eslint-disable-next-line @next/next/no-img-element */}
129
+ <img
130
+ src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design%20with%20books%20and%20laptop%20clean%20style&image_size=landscape_16_9"}
131
+ alt={course.title}
132
+ className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
133
+ />
134
+ </div>
135
+ <div className="p-5 flex flex-col flex-1">
136
+ <div className="text-xs font-medium text-blue-600 mb-2">{course.category || '通用'}</div>
137
+ <h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">{course.title}</h3>
138
+ <p className="text-gray-500 text-sm mb-4 line-clamp-2 flex-1">
139
+ {course.description ? course.description.replace(/<[^>]*>?/gm, '') : ''}
140
+ </p>
141
+ <div className="flex items-center justify-between mt-auto">
142
+ <span className="text-xl font-bold text-gray-900">
143
+ {Number(course.price) === 0 ? '免费' : `¥${course.price}`}
144
+ </span>
145
+ <div className="flex items-center text-gray-500 text-sm gap-5">
146
+ <div className="flex items-center">
147
+ <ThumbsUp className="w-4 h-4 mr-1.5" />
148
+ <span>{course.likeCount || 0}</span>
149
+ </div>
150
+ <div className="flex items-center">
151
+ <Star className="w-4 h-4 mr-1.5" />
152
+ <span>{course.starCount || 0}</span>
153
+ </div>
154
+ <div className="flex items-center">
155
+ <Eye className="w-4 h-4 mr-1.5" />
156
+ <span>{course.viewCount || 0}</span>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </div>
162
+ </Link>
163
+ );
164
+ }
frontend/src/app/user/stars/page.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { api } from "@/lib/api";
5
+ import { BookOpen, Search, Eye, ThumbsUp, Star } from "lucide-react";
6
+ import Link from "next/link";
7
+ import { useAuthStore } from "@/lib/store";
8
+ import { useRouter } from "next/navigation";
9
+
10
+ export default function UserStarsPage() {
11
+ const [courses, setCourses] = useState<any[]>([]);
12
+ const [loading, setLoading] = useState(true);
13
+ const [searchQuery, setSearchQuery] = useState("");
14
+ const { token, user } = useAuthStore();
15
+ const router = useRouter();
16
+
17
+ useEffect(() => {
18
+ if (!token) {
19
+ router.push('/login');
20
+ return;
21
+ }
22
+
23
+ const fetchCourses = async () => {
24
+ try {
25
+ const res = await api.get('/api/courses/my-stars');
26
+ if (res.success) {
27
+ setCourses(res.data);
28
+ }
29
+ } catch (err) {
30
+ console.error(err);
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ };
35
+
36
+ fetchCourses();
37
+ }, [token, router]);
38
+
39
+ const filteredCourses = courses.filter(course =>
40
+ course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
41
+ course.description?.toLowerCase().includes(searchQuery.toLowerCase())
42
+ );
43
+
44
+ return (
45
+ <div className="space-y-8">
46
+ {/* Search Section */}
47
+ <section className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
48
+ <div className="relative">
49
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
50
+ <input
51
+ type="text"
52
+ placeholder="在收藏中搜索..."
53
+ value={searchQuery}
54
+ onChange={(e) => setSearchQuery(e.target.value)}
55
+ className="w-full pl-12 pr-4 py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all text-gray-900 placeholder:text-gray-400"
56
+ />
57
+ </div>
58
+ </section>
59
+
60
+ {/* Course List */}
61
+ {loading ? (
62
+ <section>
63
+ <div className="flex items-center justify-between mb-6">
64
+ <h2 className="text-2xl font-bold text-gray-900">加载中...</h2>
65
+ </div>
66
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
67
+ {[1, 2, 3].map((i) => (
68
+ <div key={i} className="bg-white rounded-xl h-80 animate-pulse"></div>
69
+ ))}
70
+ </div>
71
+ </section>
72
+ ) : filteredCourses.length > 0 ? (
73
+ <section>
74
+ <div className="flex items-center justify-between mb-6">
75
+ <h2 className="text-2xl font-bold text-gray-900">
76
+ {searchQuery ? `搜索结果 - 我的收藏` : '我的收藏'}
77
+ </h2>
78
+ </div>
79
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
80
+ {filteredCourses.map((course) => (
81
+ <CourseCard key={course.id} course={course} />
82
+ ))}
83
+ </div>
84
+ </section>
85
+ ) : (
86
+ <section>
87
+ <div className="text-center py-12 bg-white rounded-xl border border-gray-100">
88
+ <BookOpen className="w-12 h-12 text-gray-300 mx-auto mb-3" />
89
+ <p className="text-gray-500">
90
+ {searchQuery ? '未找到匹配的课程' : '暂无收藏的课程'}
91
+ </p>
92
+ </div>
93
+ </section>
94
+ )}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ function CourseCard({ course }: { course: any }) {
101
+ return (
102
+ <Link href={`/course/${course.id}`}>
103
+ <div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-shadow group flex flex-col h-full">
104
+ <div className="aspect-video w-full bg-gray-200 relative overflow-hidden">
105
+ {/* eslint-disable-next-line @next/next/no-img-element */}
106
+ <img
107
+ src={course.coverImage || "https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=educational%20course%20cover%20design%20with%20books%20and%20laptop%20clean%20style&image_size=landscape_16_9"}
108
+ alt={course.title}
109
+ className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
110
+ />
111
+ </div>
112
+ <div className="p-5 flex flex-col flex-1">
113
+ <div className="text-xs font-medium text-blue-600 mb-2">{course.category || '通用'}</div>
114
+ <h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">{course.title}</h3>
115
+ <p className="text-gray-500 text-sm mb-4 line-clamp-2 flex-1">
116
+ {course.description ? course.description.replace(/<[^>]*>?/gm, '') : ''}
117
+ </p>
118
+ <div className="flex items-center justify-between mt-auto">
119
+ <span className="text-xl font-bold text-gray-900">
120
+ {Number(course.price) === 0 ? '免费' : `¥${course.price}`}
121
+ </span>
122
+ <div className="flex items-center text-gray-500 text-sm gap-5">
123
+ <div className="flex items-center">
124
+ <ThumbsUp className="w-4 h-4 mr-1.5" />
125
+ <span>{course.likeCount || 0}</span>
126
+ </div>
127
+ <div className="flex items-center text-yellow-500">
128
+ <Star className="w-4 h-4 mr-1.5 fill-current" />
129
+ <span>{course.starCount || 0}</span>
130
+ </div>
131
+ <div className="flex items-center">
132
+ <Eye className="w-4 h-4 mr-1.5" />
133
+ <span>{course.viewCount || 0}</span>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </Link>
140
+ );
141
+ }
frontend/src/components/QuillWrapper.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useRef } from 'react';
2
+ import ReactQuill from 'react-quill';
3
+ import 'react-quill/dist/quill.snow.css';
4
+ import { api } from '@/lib/api';
5
+
6
+ interface QuillWrapperProps {
7
+ value: string;
8
+ onChange: (value: string) => void;
9
+ placeholder?: string;
10
+ }
11
+
12
+ const QuillWrapper: React.FC<QuillWrapperProps> = ({ value, onChange, placeholder }) => {
13
+ const quillRef = useRef<ReactQuill>(null);
14
+
15
+ const imageHandler = () => {
16
+ const input = document.createElement('input');
17
+ input.setAttribute('type', 'file');
18
+ input.setAttribute('accept', 'image/*');
19
+ input.click();
20
+
21
+ input.onchange = async () => {
22
+ const file = input.files ? input.files[0] : null;
23
+ if (file) {
24
+ const formData = new FormData();
25
+ formData.append('file', file);
26
+
27
+ try {
28
+ const res = await api.post('/api/upload', formData, {
29
+ headers: {
30
+ 'Content-Type': 'multipart/form-data',
31
+ },
32
+ });
33
+ if (res.url) {
34
+ const quill = quillRef.current?.getEditor();
35
+ if (quill) {
36
+ const range = quill.getSelection(true);
37
+ quill.insertEmbed(range?.index || 0, 'image', res.url);
38
+ }
39
+ } else {
40
+ alert('Upload failed: ' + (res.message || 'Unknown error'));
41
+ }
42
+ } catch (error) {
43
+ console.error('Upload error:', error);
44
+ alert('Upload failed');
45
+ }
46
+ }
47
+ };
48
+ };
49
+
50
+ const modules = useMemo(
51
+ () => ({
52
+ toolbar: {
53
+ container: [
54
+ [{ header: [1, 2, 3, false] }],
55
+ ['bold', 'italic', 'underline', 'strike'],
56
+ [{ list: 'ordered' }, { list: 'bullet' }],
57
+ ['link', 'image'],
58
+ ['clean'],
59
+ ],
60
+ handlers: {
61
+ image: imageHandler,
62
+ },
63
+ },
64
+ }),
65
+ []
66
+ );
67
+
68
+ return (
69
+ <ReactQuill
70
+ ref={quillRef}
71
+ theme="snow"
72
+ value={value}
73
+ onChange={onChange}
74
+ modules={modules}
75
+ placeholder={placeholder}
76
+ />
77
+ );
78
+ };
79
+
80
+ export default QuillWrapper;
frontend/src/components/RichTextEditor.tsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import dynamic from 'next/dynamic';
4
+
5
+ const QuillWrapper = dynamic(() => import('./QuillWrapper'), { ssr: false });
6
+
7
+ interface RichTextEditorProps {
8
+ value: string;
9
+ onChange: (value: string) => void;
10
+ placeholder?: string;
11
+ }
12
+
13
+ export default function RichTextEditor(props: RichTextEditorProps) {
14
+ return (
15
+ <div className="bg-white">
16
+ <QuillWrapper {...props} />
17
+ </div>
18
+ );
19
+ }
frontend/src/lib/store.ts CHANGED
@@ -2,7 +2,7 @@ import { create } from 'zustand';
2
 
3
  interface User {
4
  id: number;
5
- phone: string;
6
  nickname: string;
7
  role: string;
8
  }
 
2
 
3
  interface User {
4
  id: number;
5
+ email: string;
6
  nickname: string;
7
  role: string;
8
  }
load-test.mjs ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+
3
+ const API_BASE = 'http://localhost:3001/api';
4
+
5
+ async function login(email, password) {
6
+ const res = await fetch(`${API_BASE}/auth/login`, {
7
+ method: 'POST',
8
+ headers: { 'Content-Type': 'application/json' },
9
+ body: JSON.stringify({ email, password })
10
+ });
11
+ const data = await res.json();
12
+ if (!res.ok || !data.success) throw new Error(`Login failed: ${JSON.stringify(data)}`);
13
+ return data.data.token;
14
+ }
15
+
16
+ async function register(email, password) {
17
+ const res = await fetch(`${API_BASE}/auth/register`, {
18
+ method: 'POST',
19
+ headers: { 'Content-Type': 'application/json' },
20
+ body: JSON.stringify({ email, password, emailCode: '123456' })
21
+ });
22
+ const data = await res.json();
23
+ if (!res.ok || !data.success) {
24
+ if (data.message === 'User already exists') {
25
+ return await login(email, password);
26
+ }
27
+ throw new Error(`Register failed: ${JSON.stringify(data)}`);
28
+ }
29
+ return data.data.token;
30
+ }
31
+
32
+ async function createCourse(token, index) {
33
+ const res = await fetch(`${API_BASE}/admin/courses`, {
34
+ method: 'POST',
35
+ headers: {
36
+ 'Content-Type': 'application/json',
37
+ 'Authorization': `Bearer ${token}`
38
+ },
39
+ body: JSON.stringify({
40
+ title: `Stress Test Course ${index}`,
41
+ description: `A course created for load testing ${index}`,
42
+ coverImage: 'https://via.placeholder.com/150',
43
+ driveLink: 'https://drive.google.com/example',
44
+ price: 9.99,
45
+ category: 'Testing'
46
+ })
47
+ });
48
+ const data = await res.json();
49
+ if (!res.ok) throw new Error(`Create course failed: ${JSON.stringify(data)}`);
50
+ return data.data.id;
51
+ }
52
+
53
+ async function createOrder(token, courseId, price) {
54
+ const res = await fetch(`${API_BASE}/orders/create`, {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/json',
58
+ 'Authorization': `Bearer ${token}`
59
+ },
60
+ body: JSON.stringify({ courseId, price })
61
+ });
62
+ const data = await res.json();
63
+ if (!res.ok) throw new Error(`Create order failed: ${JSON.stringify(data)}`);
64
+ return data.data.orderId;
65
+ }
66
+
67
+ async function preparePayment(token, orderId) {
68
+ const res = await fetch(`${API_BASE}/payment/prepare`, {
69
+ method: 'POST',
70
+ headers: {
71
+ 'Content-Type': 'application/json',
72
+ 'Authorization': `Bearer ${token}`
73
+ },
74
+ body: JSON.stringify({ orderId: String(orderId), payType: 'wechat' })
75
+ });
76
+ const data = await res.json();
77
+ if (!res.ok) throw new Error(`Prepare payment failed: ${JSON.stringify(data)}`);
78
+ return data.data;
79
+ }
80
+
81
+ async function runTest() {
82
+ console.log('Starting Load Test...');
83
+
84
+ // 1. Admin login
85
+ console.log('Logging in as admin...');
86
+ const adminToken = await login('admin@example.com', '123456');
87
+
88
+ // 2. Create courses
89
+ const NUM_COURSES = 10;
90
+ console.log(`Creating ${NUM_COURSES} courses...`);
91
+ const courseIds = [];
92
+ for (let i = 1; i <= NUM_COURSES; i++) {
93
+ const id = await createCourse(adminToken, i);
94
+ courseIds.push(id);
95
+ }
96
+ console.log(`Created course IDs: ${courseIds.join(', ')}`);
97
+
98
+ // 3. Create users
99
+ const NUM_USERS = 50;
100
+ console.log(`Creating ${NUM_USERS} users...`);
101
+ const userTokens = [];
102
+ for (let i = 1; i <= NUM_USERS; i++) {
103
+ const email = `loadtestuser${Date.now()}_${i}@example.com`;
104
+ const token = await register(email, '123456');
105
+ userTokens.push(token);
106
+ }
107
+
108
+ // 4. Stress test: all users try to buy all courses concurrently
109
+ console.log(`Starting concurrent purchase stress test for ${NUM_USERS * NUM_COURSES} requests...`);
110
+ const startTime = Date.now();
111
+ let successCount = 0;
112
+ let failCount = 0;
113
+
114
+ const tasks = [];
115
+
116
+ for (const token of userTokens) {
117
+ for (const courseId of courseIds) {
118
+ tasks.push((async () => {
119
+ try {
120
+ const orderId = await createOrder(token, courseId, 9.99);
121
+ await preparePayment(token, orderId);
122
+ successCount++;
123
+ } catch (e) {
124
+ failCount++;
125
+ console.error(e.message);
126
+ }
127
+ })());
128
+ }
129
+ }
130
+
131
+ await Promise.all(tasks);
132
+
133
+ const endTime = Date.now();
134
+ console.log('\n--- Stress Test Completed ---');
135
+ console.log(`Total Purchase Tasks: ${tasks.length}`);
136
+ console.log(`Success: ${successCount}`);
137
+ console.log(`Failed: ${failCount}`);
138
+ console.log(`Time taken: ${endTime - startTime} ms`);
139
+ console.log(`Requests per second: ${((tasks.length * 2) / ((endTime - startTime) / 1000)).toFixed(2)} req/s (2 API calls per task)`);
140
+ }
141
+
142
+ runTest().catch(console.error);
管理员账号 ADDED
@@ -0,0 +1 @@
 
 
1
+ admin@example.com (密码: 123456 )