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

Update project

Browse files
Files changed (35) hide show
  1. backend/src/admin/admin.controller.ts +20 -0
  2. backend/src/admin/admin.service.ts +62 -1
  3. backend/src/app.module.ts +14 -1
  4. backend/src/auth/auth.service.ts +4 -10
  5. backend/src/auth/dto/register.dto.ts +5 -1
  6. backend/src/config/config.controller.ts +25 -0
  7. backend/src/config/config.module.ts +13 -0
  8. backend/src/config/config.service.ts +67 -0
  9. backend/src/courses/courses.controller.ts +24 -0
  10. backend/src/courses/courses.module.ts +2 -1
  11. backend/src/courses/courses.service.ts +94 -2
  12. backend/src/entities/comment.entity.ts +58 -0
  13. backend/src/entities/course.entity.ts +4 -0
  14. backend/src/entities/order.entity.ts +13 -1
  15. backend/src/entities/system-config.entity.ts +28 -0
  16. backend/src/entities/user.entity.ts +7 -0
  17. backend/src/orders/dto/create-order.dto.ts +17 -5
  18. backend/src/orders/orders.module.ts +2 -1
  19. backend/src/orders/orders.service.ts +32 -11
  20. backend/src/payment/payment.module.ts +3 -1
  21. backend/src/payment/payment.service.ts +50 -19
  22. backend/src/seed-categories.ts +10 -7
  23. deploy/nginx/zhyjs.com.cn.conf +19 -0
  24. frontend/package-lock.json +104 -0
  25. frontend/package.json +1 -0
  26. frontend/src/app/admin/page.tsx +567 -19
  27. frontend/src/app/client-layout.tsx +159 -15
  28. frontend/src/app/course/[id]/comments.tsx +250 -0
  29. frontend/src/app/course/[id]/page.tsx +145 -47
  30. frontend/src/app/login/page.tsx +29 -15
  31. frontend/src/app/page.tsx +85 -24
  32. frontend/src/app/payment/[id]/page.tsx +15 -2
  33. frontend/src/app/user/stars/page.tsx +2 -1
  34. frontend/src/lib/store.ts +31 -0
  35. 管理员账号 +1 -1
backend/src/admin/admin.controller.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
  Param,
8
  Body,
9
  UseGuards,
 
10
  } from '@nestjs/common';
11
  import { AdminService } from './admin.service';
12
  import { CreateCourseDto } from './dto/create-course.dto';
@@ -47,4 +48,23 @@ export class AdminController {
47
  const data = await this.adminService.getOrders();
48
  return { success: true, data };
49
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  }
 
7
  Param,
8
  Body,
9
  UseGuards,
10
+ Query,
11
  } from '@nestjs/common';
12
  import { AdminService } from './admin.service';
13
  import { CreateCourseDto } from './dto/create-course.dto';
 
48
  const data = await this.adminService.getOrders();
49
  return { success: true, data };
50
  }
51
+
52
+ @Get('statistics')
53
+ async getStatistics(
54
+ @Query('startDate') startDate?: string,
55
+ @Query('endDate') endDate?: string,
56
+ ) {
57
+ const data = await this.adminService.getStatistics(startDate, endDate);
58
+ return { success: true, data };
59
+ }
60
+
61
+ @Get('statistics/details')
62
+ async getStatisticsDetails(
63
+ @Query('type') type: string,
64
+ @Query('startDate') startDate?: string,
65
+ @Query('endDate') endDate?: string,
66
+ ) {
67
+ const data = await this.adminService.getStatisticsDetails(type, startDate, endDate);
68
+ return { success: true, data };
69
+ }
70
  }
backend/src/admin/admin.service.ts CHANGED
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
2
  import { InjectRepository } from '@nestjs/typeorm';
3
  import { Repository } from 'typeorm';
4
  import { Course } from '../entities/course.entity';
5
- import { Order } from '../entities/order.entity';
6
  import { CreateCourseDto } from './dto/create-course.dto';
7
 
8
  @Injectable()
@@ -34,4 +34,65 @@ export class AdminService {
34
  order: { createdAt: 'DESC' },
35
  });
36
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
 
2
  import { InjectRepository } from '@nestjs/typeorm';
3
  import { Repository } from 'typeorm';
4
  import { Course } from '../entities/course.entity';
5
+ import { Order, OrderStatus, OrderType } from '../entities/order.entity';
6
  import { CreateCourseDto } from './dto/create-course.dto';
7
 
8
  @Injectable()
 
34
  order: { createdAt: 'DESC' },
35
  });
36
  }
37
+
38
+ async getStatistics(startDate?: string, endDate?: string) {
39
+ const query = this.orderRepository.createQueryBuilder('order')
40
+ .select('order.orderType', 'orderType')
41
+ .addSelect('SUM(order.amount)', 'total')
42
+ .where('order.status = :status', { status: OrderStatus.PAID });
43
+
44
+ if (startDate) {
45
+ query.andWhere('order.createdAt >= :startDate', { startDate: new Date(startDate) });
46
+ }
47
+
48
+ if (endDate) {
49
+ const end = new Date(endDate);
50
+ end.setDate(end.getDate() + 1);
51
+ query.andWhere('order.createdAt < :endDate', { endDate: end });
52
+ }
53
+
54
+ const results = await query.groupBy('order.orderType').getRawMany();
55
+
56
+ const stats = {
57
+ vipAmount: 0,
58
+ donationAmount: 0,
59
+ purchaseAmount: 0,
60
+ };
61
+
62
+ results.forEach(row => {
63
+ const total = Number(row.total) || 0;
64
+ if (row.orderType === OrderType.VIP) {
65
+ stats.vipAmount += total;
66
+ } else if (row.orderType === OrderType.DONATION) {
67
+ stats.donationAmount += total;
68
+ } else if (row.orderType === OrderType.PURCHASE) {
69
+ stats.purchaseAmount += total;
70
+ }
71
+ });
72
+
73
+ return stats;
74
+ }
75
+
76
+ async getStatisticsDetails(type: string, startDate?: string, endDate?: string) {
77
+ const query = this.orderRepository.createQueryBuilder('order')
78
+ .leftJoinAndSelect('order.user', 'user')
79
+ .leftJoinAndSelect('order.course', 'course')
80
+ .where('order.status = :status', { status: OrderStatus.PAID });
81
+
82
+ if (type && type !== 'all') {
83
+ query.andWhere('order.orderType = :type', { type });
84
+ }
85
+
86
+ if (startDate) {
87
+ query.andWhere('order.createdAt >= :startDate', { startDate: new Date(startDate) });
88
+ }
89
+
90
+ if (endDate) {
91
+ const end = new Date(endDate);
92
+ end.setDate(end.getDate() + 1);
93
+ query.andWhere('order.createdAt < :endDate', { endDate: end });
94
+ }
95
+
96
+ return query.orderBy('order.createdAt', 'DESC').getMany();
97
+ }
98
  }
backend/src/app.module.ts CHANGED
@@ -9,11 +9,14 @@ 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';
15
  import { AdminModule } from './admin/admin.module';
16
  import { PaymentModule } from './payment/payment.module';
 
17
 
18
  @Module({
19
  imports: [
@@ -23,7 +26,16 @@ import { PaymentModule } from './payment/payment.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,
@@ -31,6 +43,7 @@ import { PaymentModule } from './payment/payment.module';
31
  OrdersModule,
32
  AdminModule,
33
  PaymentModule,
 
34
  ],
35
  controllers: [AppController],
36
  providers: [AppService],
 
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 { SystemConfig } from './entities/system-config.entity';
13
+ import { Comment } from './entities/comment.entity';
14
  import { AuthModule } from './auth/auth.module';
15
  import { CoursesModule } from './courses/courses.module';
16
  import { OrdersModule } from './orders/orders.module';
17
  import { AdminModule } from './admin/admin.module';
18
  import { PaymentModule } from './payment/payment.module';
19
+ import { AppConfigModule } from './config/config.module';
20
 
21
  @Module({
22
  imports: [
 
26
  TypeOrmModule.forRoot({
27
  type: 'sqlite',
28
  database: 'course_subscription.sqlite',
29
+ entities: [
30
+ User,
31
+ Course,
32
+ Order,
33
+ Payment,
34
+ UserCourse,
35
+ UserStar,
36
+ SystemConfig,
37
+ Comment,
38
+ ],
39
  synchronize: true, // Auto-create tables in dev
40
  }),
41
  AuthModule,
 
43
  OrdersModule,
44
  AdminModule,
45
  PaymentModule,
46
+ AppConfigModule,
47
  ],
48
  controllers: [AppController],
49
  providers: [AppService],
backend/src/auth/auth.service.ts CHANGED
@@ -46,7 +46,7 @@ export class AuthService implements OnModuleInit {
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') {
@@ -66,7 +66,7 @@ export class AuthService implements OnModuleInit {
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
 
@@ -94,20 +94,14 @@ export class AuthService implements OnModuleInit {
94
  token: this.jwtService.sign(payload),
95
  role: user.role,
96
  nickname: user.nickname,
 
97
  };
98
  }
99
 
100
  async getProfile(userId: number) {
101
  const user = await this.userRepository.findOne({
102
  where: { id: userId },
103
- select: [
104
- 'id',
105
- 'email',
106
- 'nickname',
107
- 'avatar',
108
- 'role',
109
- 'createdAt',
110
- ],
111
  });
112
 
113
  if (!user) {
 
46
  }
47
 
48
  async register(registerDto: RegisterDto) {
49
+ const { email, password, emailCode, nickname } = registerDto;
50
 
51
  // In a real app, verify emailCode here
52
  if (emailCode !== '123456') {
 
66
  const user = this.userRepository.create({
67
  email,
68
  passwordHash,
69
+ nickname: nickname || `User_${email.split('@')[0].slice(0, 6)}`,
70
  role: UserRole.USER,
71
  });
72
 
 
94
  token: this.jwtService.sign(payload),
95
  role: user.role,
96
  nickname: user.nickname,
97
+ isVip: user.isVip,
98
  };
99
  }
100
 
101
  async getProfile(userId: number) {
102
  const user = await this.userRepository.findOne({
103
  where: { id: userId },
104
+ select: ['id', 'email', 'nickname', 'avatar', 'role', 'isVip', 'createdAt'],
 
 
 
 
 
 
 
105
  });
106
 
107
  if (!user) {
backend/src/auth/dto/register.dto.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { IsString, IsNotEmpty, Length, IsEmail } from 'class-validator';
2
 
3
  export class RegisterDto {
4
  @IsEmail()
@@ -10,6 +10,10 @@ export class RegisterDto {
10
  @Length(6, 20)
11
  password: string;
12
 
 
 
 
 
13
  @IsString()
14
  @IsNotEmpty()
15
  emailCode: string;
 
1
+ import { IsString, IsNotEmpty, Length, IsEmail, IsOptional } from 'class-validator';
2
 
3
  export class RegisterDto {
4
  @IsEmail()
 
10
  @Length(6, 20)
11
  password: string;
12
 
13
+ @IsString()
14
+ @IsOptional()
15
+ nickname?: string;
16
+
17
  @IsString()
18
  @IsNotEmpty()
19
  emailCode: string;
backend/src/config/config.controller.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Controller, Get, Put, Body, UseGuards } from '@nestjs/common';
2
+ import { ConfigService } from './config.service';
3
+ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
4
+ import { RolesGuard } from '../auth/guards/roles.guard';
5
+ import { Roles } from '../auth/decorators/roles.decorator';
6
+ import { UserRole } from '../entities/user.entity';
7
+
8
+ @Controller('api/config')
9
+ export class ConfigController {
10
+ constructor(private readonly configService: ConfigService) {}
11
+
12
+ @Get('ui')
13
+ async getUiConfig() {
14
+ const data = await this.configService.getUiConfig();
15
+ return { success: true, data };
16
+ }
17
+
18
+ @UseGuards(JwtAuthGuard, RolesGuard)
19
+ @Roles(UserRole.ADMIN)
20
+ @Put('ui')
21
+ async updateUiConfig(@Body() body: any) {
22
+ const data = await this.configService.updateUiConfig(body);
23
+ return { success: true, data };
24
+ }
25
+ }
backend/src/config/config.module.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Module } from '@nestjs/common';
2
+ import { TypeOrmModule } from '@nestjs/typeorm';
3
+ import { ConfigController } from './config.controller';
4
+ import { ConfigService } from './config.service';
5
+ import { SystemConfig } from '../entities/system-config.entity';
6
+
7
+ @Module({
8
+ imports: [TypeOrmModule.forFeature([SystemConfig])],
9
+ controllers: [ConfigController],
10
+ providers: [ConfigService],
11
+ exports: [ConfigService],
12
+ })
13
+ export class AppConfigModule {}
backend/src/config/config.service.ts ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { InjectRepository } from '@nestjs/typeorm';
3
+ import { Repository } from 'typeorm';
4
+ import { SystemConfig } from '../entities/system-config.entity';
5
+
6
+ export const DEFAULT_CONFIG = {
7
+ siteName: '极简AI',
8
+ logo: '',
9
+ footerText: '© ' + new Date().getFullYear() + ' 极简AI. 保留所有权利。',
10
+ navLinks: [
11
+ { label: 'AI新资讯', value: 'ai-news' },
12
+ { label: 'Comfyui资讯', value: 'comfyui' },
13
+ { label: '满血整合包', value: 'full-blood' },
14
+ { label: '闭源API接口', value: 'closed-api' },
15
+ { label: '应用广场', value: 'app-square' },
16
+ ],
17
+ memberFee: 99,
18
+ };
19
+
20
+ @Injectable()
21
+ export class ConfigService implements OnModuleInit {
22
+ constructor(
23
+ @InjectRepository(SystemConfig)
24
+ private readonly configRepo: Repository<SystemConfig>,
25
+ ) {}
26
+
27
+ async onModuleInit() {
28
+ await this.initDefaultConfig();
29
+ }
30
+
31
+ async initDefaultConfig() {
32
+ const existingConfig = await this.configRepo.findOne({
33
+ where: { key: 'ui_config' },
34
+ });
35
+
36
+ if (!existingConfig) {
37
+ const config = this.configRepo.create({
38
+ key: 'ui_config',
39
+ value: DEFAULT_CONFIG,
40
+ description: 'Global UI Configuration',
41
+ });
42
+ await this.configRepo.save(config);
43
+ console.log('Default UI configuration initialized.');
44
+ }
45
+ }
46
+
47
+ async getUiConfig() {
48
+ const config = await this.configRepo.findOne({
49
+ where: { key: 'ui_config' },
50
+ });
51
+ return config?.value || DEFAULT_CONFIG;
52
+ }
53
+
54
+ async updateUiConfig(value: any) {
55
+ let config = await this.configRepo.findOne({
56
+ where: { key: 'ui_config' },
57
+ });
58
+
59
+ if (!config) {
60
+ config = this.configRepo.create({ key: 'ui_config' });
61
+ }
62
+
63
+ config.value = { ...config.value, ...value };
64
+ await this.configRepo.save(config);
65
+ return config.value;
66
+ }
67
+ }
backend/src/courses/courses.controller.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
  UseGuards,
7
  Request,
8
  ParseIntPipe,
 
9
  } from '@nestjs/common';
10
  import { CoursesService } from './courses.service';
11
  import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -65,4 +66,27 @@ export class CoursesController {
65
  const data = await this.coursesService.getCourseAccess(req.user.userId, id);
66
  return { success: true, data };
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
 
6
  UseGuards,
7
  Request,
8
  ParseIntPipe,
9
+ Body,
10
  } from '@nestjs/common';
11
  import { CoursesService } from './courses.service';
12
  import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
 
66
  const data = await this.coursesService.getCourseAccess(req.user.userId, id);
67
  return { success: true, data };
68
  }
69
+
70
+ @Get(':id/comments')
71
+ async getComments(@Param('id', ParseIntPipe) id: number) {
72
+ const data = await this.coursesService.getComments(id);
73
+ return { success: true, data };
74
+ }
75
+
76
+ @UseGuards(JwtAuthGuard)
77
+ @Post(':id/comments')
78
+ async addComment(
79
+ @Param('id', ParseIntPipe) id: number,
80
+ @Body() body: { content: string; parentId?: number; replyToCommentId?: number },
81
+ @Request() req,
82
+ ) {
83
+ const data = await this.coursesService.addComment(
84
+ id,
85
+ req.user.userId,
86
+ body.content,
87
+ body.parentId,
88
+ body.replyToCommentId,
89
+ );
90
+ return { success: true, data };
91
+ }
92
  }
backend/src/courses/courses.module.ts CHANGED
@@ -5,9 +5,10 @@ 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],
 
5
  import { Course } from '../entities/course.entity';
6
  import { UserCourse } from '../entities/user-course.entity';
7
  import { UserStar } from '../entities/user-star.entity';
8
+ import { Comment } from '../entities/comment.entity';
9
 
10
  @Module({
11
+ imports: [TypeOrmModule.forFeature([Course, UserCourse, UserStar, Comment])],
12
  providers: [CoursesService],
13
  controllers: [CoursesController],
14
  exports: [CoursesService],
backend/src/courses/courses.service.ts CHANGED
@@ -8,6 +8,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 {
@@ -18,12 +19,24 @@ export class CoursesService {
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,7 +44,17 @@ export class CoursesService {
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) {
@@ -100,6 +123,75 @@ export class CoursesService {
100
  }));
101
  }
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  async getCourseAccess(userId: number, courseId: number) {
104
  const userCourse = await this.userCourseRepository.findOne({
105
  where: { userId, courseId },
 
8
  import { Course } from '../entities/course.entity';
9
  import { UserCourse } from '../entities/user-course.entity';
10
  import { UserStar } from '../entities/user-star.entity';
11
+ import { Comment } from '../entities/comment.entity';
12
 
13
  @Injectable()
14
  export class CoursesService {
 
19
  private userCourseRepository: Repository<UserCourse>,
20
  @InjectRepository(UserStar)
21
  private userStarRepository: Repository<UserStar>,
22
+ @InjectRepository(Comment)
23
+ private commentRepository: Repository<Comment>,
24
  ) {}
25
 
26
  async findAll() {
27
  return this.courseRepository.find({
28
  where: { isActive: true },
29
+ select: [
30
+ 'id',
31
+ 'title',
32
+ 'description',
33
+ 'coverImage',
34
+ 'price',
35
+ 'category',
36
+ 'viewCount',
37
+ 'likeCount',
38
+ 'starCount',
39
+ ],
40
  order: { createdAt: 'DESC' },
41
  });
42
  }
 
44
  async findOne(id: number) {
45
  const course = await this.courseRepository.findOne({
46
  where: { id, isActive: true },
47
+ select: [
48
+ 'id',
49
+ 'title',
50
+ 'description',
51
+ 'coverImage',
52
+ 'price',
53
+ 'category',
54
+ 'viewCount',
55
+ 'likeCount',
56
+ 'starCount',
57
+ ],
58
  });
59
 
60
  if (!course) {
 
123
  }));
124
  }
125
 
126
+ async getComments(courseId: number) {
127
+ const comments = await this.commentRepository.find({
128
+ where: { courseId },
129
+ relations: ['user', 'replyToComment', 'replyToComment.user'],
130
+ order: { createdAt: 'ASC' },
131
+ });
132
+
133
+ return comments.map(c => ({
134
+ id: c.id,
135
+ courseId: c.courseId,
136
+ userId: c.userId,
137
+ parentId: c.parentId,
138
+ replyToCommentId: c.replyToCommentId,
139
+ content: c.content,
140
+ createdAt: c.createdAt,
141
+ user: {
142
+ id: c.user.id,
143
+ nickname: c.user.nickname,
144
+ avatar: c.user.avatar,
145
+ isVip: c.user.isVip,
146
+ },
147
+ replyToUser: c.replyToComment ? {
148
+ id: c.replyToComment.user.id,
149
+ nickname: c.replyToComment.user.nickname,
150
+ } : undefined
151
+ }));
152
+ }
153
+
154
+ async addComment(courseId: number, userId: number, content: string, parentId?: number, replyToCommentId?: number) {
155
+ const course = await this.courseRepository.findOne({ where: { id: courseId } });
156
+ if (!course) throw new NotFoundException('Course not found');
157
+
158
+ const comment = this.commentRepository.create({
159
+ courseId,
160
+ userId,
161
+ content,
162
+ parentId,
163
+ replyToCommentId,
164
+ });
165
+ await this.commentRepository.save(comment);
166
+
167
+ const savedComment = await this.commentRepository.findOne({
168
+ where: { id: comment.id },
169
+ relations: ['user', 'replyToComment', 'replyToComment.user']
170
+ });
171
+
172
+ if (!savedComment) throw new NotFoundException('Comment not found after save');
173
+
174
+ return {
175
+ id: savedComment.id,
176
+ courseId: savedComment.courseId,
177
+ userId: savedComment.userId,
178
+ parentId: savedComment.parentId,
179
+ replyToCommentId: savedComment.replyToCommentId,
180
+ content: savedComment.content,
181
+ createdAt: savedComment.createdAt,
182
+ user: {
183
+ id: savedComment.user.id,
184
+ nickname: savedComment.user.nickname,
185
+ avatar: savedComment.user.avatar,
186
+ isVip: savedComment.user.isVip,
187
+ },
188
+ replyToUser: savedComment.replyToComment ? {
189
+ id: savedComment.replyToComment.user.id,
190
+ nickname: savedComment.replyToComment.user.nickname,
191
+ } : undefined
192
+ };
193
+ }
194
+
195
  async getCourseAccess(userId: number, courseId: number) {
196
  const userCourse = await this.userCourseRepository.findOne({
197
  where: { userId, courseId },
backend/src/entities/comment.entity.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Entity,
3
+ Column,
4
+ PrimaryGeneratedColumn,
5
+ CreateDateColumn,
6
+ UpdateDateColumn,
7
+ ManyToOne,
8
+ JoinColumn,
9
+ OneToMany,
10
+ } from 'typeorm';
11
+ import { User } from './user.entity';
12
+ import { Course } from './course.entity';
13
+
14
+ @Entity('comments')
15
+ export class Comment {
16
+ @PrimaryGeneratedColumn()
17
+ id: number;
18
+
19
+ @Column({ name: 'course_id' })
20
+ courseId: number;
21
+
22
+ @Column({ name: 'user_id' })
23
+ userId: number;
24
+
25
+ @Column({ name: 'parent_id', nullable: true })
26
+ parentId: number;
27
+
28
+ @Column({ name: 'reply_to_comment_id', nullable: true })
29
+ replyToCommentId: number;
30
+
31
+ @Column({ type: 'text' })
32
+ content: string;
33
+
34
+ @CreateDateColumn({ name: 'created_at' })
35
+ createdAt: Date;
36
+
37
+ @UpdateDateColumn({ name: 'updated_at' })
38
+ updatedAt: Date;
39
+
40
+ @ManyToOne(() => User, (user) => user.comments)
41
+ @JoinColumn({ name: 'user_id' })
42
+ user: User;
43
+
44
+ @ManyToOne(() => Course, (course) => course.comments)
45
+ @JoinColumn({ name: 'course_id' })
46
+ course: Course;
47
+
48
+ @ManyToOne(() => Comment, (comment) => comment.replies)
49
+ @JoinColumn({ name: 'parent_id' })
50
+ parent: Comment;
51
+
52
+ @ManyToOne(() => Comment)
53
+ @JoinColumn({ name: 'reply_to_comment_id' })
54
+ replyToComment: Comment;
55
+
56
+ @OneToMany(() => Comment, (comment) => comment.parent)
57
+ replies: Comment[];
58
+ }
backend/src/entities/course.entity.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
  } from 'typeorm';
9
  import { Order } from './order.entity';
10
  import { UserCourse } from './user-course.entity';
 
11
 
12
  @Entity('courses')
13
  export class Course {
@@ -55,4 +56,7 @@ export class Course {
55
 
56
  @OneToMany(() => UserCourse, (userCourse) => userCourse.course)
57
  userCourses: UserCourse[];
 
 
 
58
  }
 
8
  } from 'typeorm';
9
  import { Order } from './order.entity';
10
  import { UserCourse } from './user-course.entity';
11
+ import { Comment } from './comment.entity';
12
 
13
  @Entity('courses')
14
  export class Course {
 
56
 
57
  @OneToMany(() => UserCourse, (userCourse) => userCourse.course)
58
  userCourses: UserCourse[];
59
+
60
+ @OneToMany(() => Comment, (comment) => comment.course)
61
+ comments: Comment[];
62
  }
backend/src/entities/order.entity.ts CHANGED
@@ -18,6 +18,12 @@ export enum OrderStatus {
18
  REFUNDED = 'refunded',
19
  }
20
 
 
 
 
 
 
 
21
  @Entity('orders')
22
  export class Order {
23
  @PrimaryGeneratedColumn()
@@ -29,7 +35,7 @@ export class Order {
29
  @Column({ name: 'user_id' })
30
  userId: number;
31
 
32
- @Column({ name: 'course_id' })
33
  courseId: number;
34
 
35
  @Column({ type: 'decimal', precision: 10, scale: 2 })
@@ -38,6 +44,12 @@ export class Order {
38
  @Column({ type: 'varchar', default: OrderStatus.PENDING })
39
  status: OrderStatus;
40
 
 
 
 
 
 
 
41
  @CreateDateColumn({ name: 'created_at' })
42
  createdAt: Date;
43
 
 
18
  REFUNDED = 'refunded',
19
  }
20
 
21
+ export enum OrderType {
22
+ PURCHASE = 'purchase',
23
+ DONATION = 'donation',
24
+ VIP = 'vip',
25
+ }
26
+
27
  @Entity('orders')
28
  export class Order {
29
  @PrimaryGeneratedColumn()
 
35
  @Column({ name: 'user_id' })
36
  userId: number;
37
 
38
+ @Column({ name: 'course_id', nullable: true })
39
  courseId: number;
40
 
41
  @Column({ type: 'decimal', precision: 10, scale: 2 })
 
44
  @Column({ type: 'varchar', default: OrderStatus.PENDING })
45
  status: OrderStatus;
46
 
47
+ @Column({ type: 'varchar', default: OrderType.PURCHASE, name: 'order_type' })
48
+ orderType: OrderType;
49
+
50
+ @Column({ type: 'varchar', length: 255, nullable: true })
51
+ message: string;
52
+
53
  @CreateDateColumn({ name: 'created_at' })
54
  createdAt: Date;
55
 
backend/src/entities/system-config.entity.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Entity,
3
+ Column,
4
+ PrimaryGeneratedColumn,
5
+ CreateDateColumn,
6
+ UpdateDateColumn,
7
+ } from 'typeorm';
8
+
9
+ @Entity('system_configs')
10
+ export class SystemConfig {
11
+ @PrimaryGeneratedColumn()
12
+ id: number;
13
+
14
+ @Column({ type: 'varchar', length: 100, unique: true })
15
+ key: string;
16
+
17
+ @Column({ type: 'simple-json', nullable: true })
18
+ value: any;
19
+
20
+ @Column({ type: 'varchar', length: 255, nullable: true })
21
+ description: string;
22
+
23
+ @CreateDateColumn({ name: 'created_at' })
24
+ createdAt: Date;
25
+
26
+ @UpdateDateColumn({ name: 'updated_at' })
27
+ updatedAt: Date;
28
+ }
backend/src/entities/user.entity.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
  } from 'typeorm';
9
  import { Order } from './order.entity';
10
  import { UserCourse } from './user-course.entity';
 
11
 
12
  export enum UserRole {
13
  USER = 'user',
@@ -31,6 +32,9 @@ export class User {
31
  @Column({ type: 'varchar', length: 255, nullable: true })
32
  avatar: string;
33
 
 
 
 
34
  @Column({ type: 'varchar', default: UserRole.USER })
35
  role: UserRole;
36
 
@@ -45,4 +49,7 @@ export class User {
45
 
46
  @OneToMany(() => UserCourse, (userCourse) => userCourse.user)
47
  userCourses: UserCourse[];
 
 
 
48
  }
 
8
  } from 'typeorm';
9
  import { Order } from './order.entity';
10
  import { UserCourse } from './user-course.entity';
11
+ import { Comment } from './comment.entity';
12
 
13
  export enum UserRole {
14
  USER = 'user',
 
32
  @Column({ type: 'varchar', length: 255, nullable: true })
33
  avatar: string;
34
 
35
+ @Column({ type: 'boolean', default: false, name: 'is_vip' })
36
+ isVip: boolean;
37
+
38
  @Column({ type: 'varchar', default: UserRole.USER })
39
  role: UserRole;
40
 
 
49
 
50
  @OneToMany(() => UserCourse, (userCourse) => userCourse.user)
51
  userCourses: UserCourse[];
52
+
53
+ @OneToMany(() => Comment, (comment) => comment.user)
54
+ comments: Comment[];
55
  }
backend/src/orders/dto/create-order.dto.ts CHANGED
@@ -1,11 +1,23 @@
1
- import { IsNumber, IsNotEmpty } from 'class-validator';
2
 
3
  export class CreateOrderDto {
4
  @IsNumber()
5
- @IsNotEmpty()
6
- courseId: number;
7
 
8
  @IsNumber()
9
- @IsNotEmpty()
10
- price: number;
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
 
1
+ import { IsNumber, IsNotEmpty, IsOptional, IsBoolean, IsString } from 'class-validator';
2
 
3
  export class CreateOrderDto {
4
  @IsNumber()
5
+ @IsOptional()
6
+ courseId?: number;
7
 
8
  @IsNumber()
9
+ @IsOptional()
10
+ price?: number;
11
+
12
+ @IsBoolean()
13
+ @IsOptional()
14
+ isDonation?: boolean;
15
+
16
+ @IsBoolean()
17
+ @IsOptional()
18
+ isVip?: boolean;
19
+
20
+ @IsString()
21
+ @IsOptional()
22
+ message?: string;
23
  }
backend/src/orders/orders.module.ts CHANGED
@@ -4,9 +4,10 @@ import { OrdersService } from './orders.service';
4
  import { OrdersController } from './orders.controller';
5
  import { Order } from '../entities/order.entity';
6
  import { Course } from '../entities/course.entity';
 
7
 
8
  @Module({
9
- imports: [TypeOrmModule.forFeature([Order, Course])],
10
  providers: [OrdersService],
11
  controllers: [OrdersController],
12
  exports: [OrdersService],
 
4
  import { OrdersController } from './orders.controller';
5
  import { Order } from '../entities/order.entity';
6
  import { Course } from '../entities/course.entity';
7
+ import { AppConfigModule } from '../config/config.module';
8
 
9
  @Module({
10
+ imports: [TypeOrmModule.forFeature([Order, Course]), AppConfigModule],
11
  providers: [OrdersService],
12
  controllers: [OrdersController],
13
  exports: [OrdersService],
backend/src/orders/orders.service.ts CHANGED
@@ -5,9 +5,10 @@ import {
5
  } from '@nestjs/common';
6
  import { InjectRepository } from '@nestjs/typeorm';
7
  import { Repository } from 'typeorm';
8
- import { Order, OrderStatus } from '../entities/order.entity';
9
  import { Course } from '../entities/course.entity';
10
  import { CreateOrderDto } from './dto/create-order.dto';
 
11
 
12
  @Injectable()
13
  export class OrdersService {
@@ -16,18 +17,36 @@ export class OrdersService {
16
  private orderRepository: Repository<Order>,
17
  @InjectRepository(Course)
18
  private courseRepository: Repository<Course>,
 
19
  ) {}
20
 
21
  async create(userId: number, createOrderDto: CreateOrderDto) {
22
- const course = await this.courseRepository.findOne({
23
- where: { id: createOrderDto.courseId },
24
- });
25
- if (!course) {
26
- throw new NotFoundException('Course not found');
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
- if (Number(course.price) !== Number(createOrderDto.price)) {
30
- throw new BadRequestException('Price mismatch');
 
31
  }
32
 
33
  // Generate simple order number with timestamp to avoid collision under high concurrency
@@ -41,9 +60,11 @@ export class OrdersService {
41
  const order = this.orderRepository.create({
42
  orderNo,
43
  userId,
44
- courseId: course.id,
45
- amount: course.price,
46
  status: OrderStatus.PENDING,
 
 
47
  });
48
 
49
  await this.orderRepository.save(order);
 
5
  } from '@nestjs/common';
6
  import { InjectRepository } from '@nestjs/typeorm';
7
  import { Repository } from 'typeorm';
8
+ import { Order, OrderStatus, OrderType } from '../entities/order.entity';
9
  import { Course } from '../entities/course.entity';
10
  import { CreateOrderDto } from './dto/create-order.dto';
11
+ import { ConfigService } from '../config/config.service';
12
 
13
  @Injectable()
14
  export class OrdersService {
 
17
  private orderRepository: Repository<Order>,
18
  @InjectRepository(Course)
19
  private courseRepository: Repository<Course>,
20
+ private configService: ConfigService,
21
  ) {}
22
 
23
  async create(userId: number, createOrderDto: CreateOrderDto) {
24
+ let amount = 0;
25
+ let orderType = OrderType.PURCHASE;
26
+ let courseId = null;
27
+
28
+ if (createOrderDto.isVip) {
29
+ const config = await this.configService.getUiConfig();
30
+ amount = config.memberFee || 99;
31
+ orderType = OrderType.VIP;
32
+ } else {
33
+ if (!createOrderDto.courseId) {
34
+ throw new BadRequestException('Course ID is required for non-VIP orders');
35
+ }
36
+ const course = await this.courseRepository.findOne({
37
+ where: { id: createOrderDto.courseId },
38
+ });
39
+ if (!course) {
40
+ throw new NotFoundException('Course not found');
41
+ }
42
+
43
+ if (!createOrderDto.isDonation && Number(course.price) !== Number(createOrderDto.price)) {
44
+ throw new BadRequestException('Price mismatch');
45
+ }
46
 
47
+ amount = createOrderDto.isDonation ? (createOrderDto.price || 0) : course.price;
48
+ orderType = createOrderDto.isDonation ? OrderType.DONATION : OrderType.PURCHASE;
49
+ courseId = course.id;
50
  }
51
 
52
  // Generate simple order number with timestamp to avoid collision under high concurrency
 
60
  const order = this.orderRepository.create({
61
  orderNo,
62
  userId,
63
+ courseId,
64
+ amount,
65
  status: OrderStatus.PENDING,
66
+ orderType,
67
+ message: createOrderDto.message,
68
  });
69
 
70
  await this.orderRepository.save(order);
backend/src/payment/payment.module.ts CHANGED
@@ -5,9 +5,11 @@ import { PaymentController } from './payment.controller';
5
  import { Payment } from '../entities/payment.entity';
6
  import { Order } from '../entities/order.entity';
7
  import { UserCourse } from '../entities/user-course.entity';
 
 
8
 
9
  @Module({
10
- imports: [TypeOrmModule.forFeature([Payment, Order, UserCourse])],
11
  providers: [PaymentService],
12
  controllers: [PaymentController],
13
  })
 
5
  import { Payment } from '../entities/payment.entity';
6
  import { Order } from '../entities/order.entity';
7
  import { UserCourse } from '../entities/user-course.entity';
8
+ import { User } from '../entities/user.entity';
9
+ import { Course } from '../entities/course.entity';
10
 
11
  @Module({
12
+ imports: [TypeOrmModule.forFeature([Payment, Order, UserCourse, User, Course])],
13
  providers: [PaymentService],
14
  controllers: [PaymentController],
15
  })
backend/src/payment/payment.service.ts CHANGED
@@ -7,9 +7,11 @@ import { InjectRepository } from '@nestjs/typeorm';
7
  import { Repository } from 'typeorm';
8
  import * as crypto from 'crypto';
9
  import { Payment, PaymentStatus, PayType } from '../entities/payment.entity';
10
- import { Order, OrderStatus } from '../entities/order.entity';
11
  import { UserCourse } from '../entities/user-course.entity';
12
  import { PreparePaymentDto } from './dto/prepare-payment.dto';
 
 
13
 
14
  @Injectable()
15
  export class PaymentService {
@@ -20,6 +22,10 @@ export class PaymentService {
20
  private orderRepository: Repository<Order>,
21
  @InjectRepository(UserCourse)
22
  private userCourseRepository: Repository<UserCourse>,
 
 
 
 
23
  ) {}
24
 
25
  async prepare(userId: number, preparePaymentDto: PreparePaymentDto) {
@@ -80,24 +86,49 @@ export class PaymentService {
80
  order.paidAt = new Date();
81
  await this.orderRepository.save(order);
82
 
83
- // Grant access
84
- const expiredAt = new Date();
85
- expiredAt.setFullYear(expiredAt.getFullYear() + 1); // 1 year access
86
-
87
- const accessToken = crypto.randomBytes(32).toString('hex');
88
-
89
- const userCourse = this.userCourseRepository.create({
90
- userId: order.userId,
91
- courseId: order.courseId,
92
- accessToken,
93
- expiredAt,
94
- });
95
-
96
- // Handle duplicate purchase gracefully
97
- try {
98
- await this.userCourseRepository.save(userCourse);
99
- } catch {
100
- // Already purchased, maybe extend expiration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  }
102
  } else {
103
  payment.status = PaymentStatus.FAILED;
 
7
  import { Repository } from 'typeorm';
8
  import * as crypto from 'crypto';
9
  import { Payment, PaymentStatus, PayType } from '../entities/payment.entity';
10
+ import { Order, OrderStatus, OrderType } from '../entities/order.entity';
11
  import { UserCourse } from '../entities/user-course.entity';
12
  import { PreparePaymentDto } from './dto/prepare-payment.dto';
13
+ import { User } from '../entities/user.entity';
14
+ import { Course } from '../entities/course.entity';
15
 
16
  @Injectable()
17
  export class PaymentService {
 
22
  private orderRepository: Repository<Order>,
23
  @InjectRepository(UserCourse)
24
  private userCourseRepository: Repository<UserCourse>,
25
+ @InjectRepository(User)
26
+ private userRepository: Repository<User>,
27
+ @InjectRepository(Course)
28
+ private courseRepository: Repository<Course>,
29
  ) {}
30
 
31
  async prepare(userId: number, preparePaymentDto: PreparePaymentDto) {
 
86
  order.paidAt = new Date();
87
  await this.orderRepository.save(order);
88
 
89
+ // Only grant access if it's a purchase order
90
+ if (order.orderType === OrderType.PURCHASE) {
91
+ // Send email with drive link instead of generating access token
92
+ const user = await this.userRepository.findOne({ where: { id: order.userId } });
93
+ const course = await this.courseRepository.findOne({ where: { id: order.courseId } });
94
+
95
+ if (user && course && course.driveLink) {
96
+ // In a real application, you would use a mail service like Nodemailer here
97
+ console.log('----------------------------------------');
98
+ console.log(`[MOCK EMAIL SERVICE] Sending course materials...`);
99
+ console.log(`To: ${user.email}`);
100
+ console.log(`Subject: 您购买的课程【${course.title}】资料`);
101
+ console.log(`Body: 感谢您的购买!您的课程资料链接如下:\n${course.driveLink}`);
102
+ console.log('----------------------------------------');
103
+ }
104
+
105
+ // We still create a UserCourse record to mark that the user has purchased it,
106
+ // but the accessToken isn't strictly needed anymore for viewing the content.
107
+ const expiredAt = new Date();
108
+ expiredAt.setFullYear(expiredAt.getFullYear() + 1); // 1 year access
109
+
110
+ const accessToken = crypto.randomBytes(32).toString('hex');
111
+
112
+ const userCourse = this.userCourseRepository.create({
113
+ userId: order.userId,
114
+ courseId: order.courseId,
115
+ accessToken,
116
+ expiredAt,
117
+ });
118
+
119
+ // Handle duplicate purchase gracefully
120
+ try {
121
+ await this.userCourseRepository.save(userCourse);
122
+ } catch {
123
+ // Already purchased, maybe extend expiration
124
+ }
125
+ } else if (order.orderType === OrderType.VIP) {
126
+ // Upgrade user to VIP
127
+ const user = await this.userRepository.findOne({ where: { id: order.userId } });
128
+ if (user) {
129
+ user.isVip = true;
130
+ await this.userRepository.save(user);
131
+ }
132
  }
133
  } else {
134
  payment.status = PaymentStatus.FAILED;
backend/src/seed-categories.ts CHANGED
@@ -8,24 +8,27 @@ const CATEGORIES = [
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
  }
 
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 =
24
+ CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)];
25
  course.category = randomCategory;
26
  await courseRepository.save(course);
27
+ console.log(
28
+ `Updated course ${course.id} "${course.title}" to category: ${randomCategory}`,
29
+ );
30
  }
31
+
32
  console.log('Finished updating categories.');
33
  await app.close();
34
  }
deploy/nginx/zhyjs.com.cn.conf ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name zhyjs.com.cn www.zhyjs.com.cn;
4
+
5
+ # 代理所有请求到前端服务 (端口默认为 Dockerfile 中的 7860)
6
+ location / {
7
+ proxy_pass http://127.0.0.1:7860;
8
+ proxy_http_version 1.1;
9
+ proxy_set_header Upgrade $http_upgrade;
10
+ proxy_set_header Connection 'upgrade';
11
+ proxy_set_header Host $host;
12
+ proxy_cache_bypass $http_upgrade;
13
+
14
+ # 传递真实 IP
15
+ proxy_set_header X-Real-IP $remote_addr;
16
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
17
+ proxy_set_header X-Forwarded-Proto $scheme;
18
+ }
19
+ }
frontend/package-lock.json CHANGED
@@ -17,6 +17,7 @@
17
  "react-dom": "^18",
18
  "react-quill": "^2.0.0",
19
  "tailwind-merge": "^3.5.0",
 
20
  "zustand": "^5.0.12"
21
  },
22
  "devDependencies": {
@@ -1175,6 +1176,15 @@
1175
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
1176
  }
1177
  },
 
 
 
 
 
 
 
 
 
1178
  "node_modules/ajv": {
1179
  "version": "6.14.0",
1180
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -1635,6 +1645,19 @@
1635
  ],
1636
  "license": "CC-BY-4.0"
1637
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1638
  "node_modules/chalk": {
1639
  "version": "4.1.2",
1640
  "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1726,6 +1749,15 @@
1726
  "node": ">=6"
1727
  }
1728
  },
 
 
 
 
 
 
 
 
 
1729
  "node_modules/color-convert": {
1730
  "version": "2.0.1",
1731
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1775,6 +1807,18 @@
1775
  "dev": true,
1776
  "license": "MIT"
1777
  },
 
 
 
 
 
 
 
 
 
 
 
 
1778
  "node_modules/cross-spawn": {
1779
  "version": "7.0.6",
1780
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2870,6 +2914,15 @@
2870
  "node": ">= 6"
2871
  }
2872
  },
 
 
 
 
 
 
 
 
 
2873
  "node_modules/fs.realpath": {
2874
  "version": "1.0.0",
2875
  "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -5330,6 +5383,18 @@
5330
  "node": ">=0.10.0"
5331
  }
5332
  },
 
 
 
 
 
 
 
 
 
 
 
 
5333
  "node_modules/stable-hash": {
5334
  "version": "0.0.5",
5335
  "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6145,6 +6210,24 @@
6145
  "url": "https://github.com/sponsors/ljharb"
6146
  }
6147
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6148
  "node_modules/word-wrap": {
6149
  "version": "1.2.5",
6150
  "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -6263,6 +6346,27 @@
6263
  "dev": true,
6264
  "license": "ISC"
6265
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6266
  "node_modules/yocto-queue": {
6267
  "version": "0.1.0",
6268
  "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
 
17
  "react-dom": "^18",
18
  "react-quill": "^2.0.0",
19
  "tailwind-merge": "^3.5.0",
20
+ "xlsx": "^0.18.5",
21
  "zustand": "^5.0.12"
22
  },
23
  "devDependencies": {
 
1176
  "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
1177
  }
1178
  },
1179
+ "node_modules/adler-32": {
1180
+ "version": "1.3.1",
1181
+ "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
1182
+ "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
1183
+ "license": "Apache-2.0",
1184
+ "engines": {
1185
+ "node": ">=0.8"
1186
+ }
1187
+ },
1188
  "node_modules/ajv": {
1189
  "version": "6.14.0",
1190
  "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
 
1645
  ],
1646
  "license": "CC-BY-4.0"
1647
  },
1648
+ "node_modules/cfb": {
1649
+ "version": "1.2.2",
1650
+ "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
1651
+ "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
1652
+ "license": "Apache-2.0",
1653
+ "dependencies": {
1654
+ "adler-32": "~1.3.0",
1655
+ "crc-32": "~1.2.0"
1656
+ },
1657
+ "engines": {
1658
+ "node": ">=0.8"
1659
+ }
1660
+ },
1661
  "node_modules/chalk": {
1662
  "version": "4.1.2",
1663
  "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
 
1749
  "node": ">=6"
1750
  }
1751
  },
1752
+ "node_modules/codepage": {
1753
+ "version": "1.15.0",
1754
+ "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
1755
+ "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
1756
+ "license": "Apache-2.0",
1757
+ "engines": {
1758
+ "node": ">=0.8"
1759
+ }
1760
+ },
1761
  "node_modules/color-convert": {
1762
  "version": "2.0.1",
1763
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 
1807
  "dev": true,
1808
  "license": "MIT"
1809
  },
1810
+ "node_modules/crc-32": {
1811
+ "version": "1.2.2",
1812
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
1813
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
1814
+ "license": "Apache-2.0",
1815
+ "bin": {
1816
+ "crc32": "bin/crc32.njs"
1817
+ },
1818
+ "engines": {
1819
+ "node": ">=0.8"
1820
+ }
1821
+ },
1822
  "node_modules/cross-spawn": {
1823
  "version": "7.0.6",
1824
  "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 
2914
  "node": ">= 6"
2915
  }
2916
  },
2917
+ "node_modules/frac": {
2918
+ "version": "1.1.2",
2919
+ "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
2920
+ "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
2921
+ "license": "Apache-2.0",
2922
+ "engines": {
2923
+ "node": ">=0.8"
2924
+ }
2925
+ },
2926
  "node_modules/fs.realpath": {
2927
  "version": "1.0.0",
2928
  "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
 
5383
  "node": ">=0.10.0"
5384
  }
5385
  },
5386
+ "node_modules/ssf": {
5387
+ "version": "0.11.2",
5388
+ "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
5389
+ "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
5390
+ "license": "Apache-2.0",
5391
+ "dependencies": {
5392
+ "frac": "~1.1.2"
5393
+ },
5394
+ "engines": {
5395
+ "node": ">=0.8"
5396
+ }
5397
+ },
5398
  "node_modules/stable-hash": {
5399
  "version": "0.0.5",
5400
  "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
 
6210
  "url": "https://github.com/sponsors/ljharb"
6211
  }
6212
  },
6213
+ "node_modules/wmf": {
6214
+ "version": "1.0.2",
6215
+ "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
6216
+ "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
6217
+ "license": "Apache-2.0",
6218
+ "engines": {
6219
+ "node": ">=0.8"
6220
+ }
6221
+ },
6222
+ "node_modules/word": {
6223
+ "version": "0.3.0",
6224
+ "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
6225
+ "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
6226
+ "license": "Apache-2.0",
6227
+ "engines": {
6228
+ "node": ">=0.8"
6229
+ }
6230
+ },
6231
  "node_modules/word-wrap": {
6232
  "version": "1.2.5",
6233
  "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
 
6346
  "dev": true,
6347
  "license": "ISC"
6348
  },
6349
+ "node_modules/xlsx": {
6350
+ "version": "0.18.5",
6351
+ "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
6352
+ "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
6353
+ "license": "Apache-2.0",
6354
+ "dependencies": {
6355
+ "adler-32": "~1.3.0",
6356
+ "cfb": "~1.2.1",
6357
+ "codepage": "~1.15.0",
6358
+ "crc-32": "~1.2.1",
6359
+ "ssf": "~0.11.2",
6360
+ "wmf": "~1.0.1",
6361
+ "word": "~0.3.0"
6362
+ },
6363
+ "bin": {
6364
+ "xlsx": "bin/xlsx.njs"
6365
+ },
6366
+ "engines": {
6367
+ "node": ">=0.8"
6368
+ }
6369
+ },
6370
  "node_modules/yocto-queue": {
6371
  "version": "0.1.0",
6372
  "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
frontend/package.json CHANGED
@@ -18,6 +18,7 @@
18
  "react-dom": "^18",
19
  "react-quill": "^2.0.0",
20
  "tailwind-merge": "^3.5.0",
 
21
  "zustand": "^5.0.12"
22
  },
23
  "devDependencies": {
 
18
  "react-dom": "^18",
19
  "react-quill": "^2.0.0",
20
  "tailwind-merge": "^3.5.0",
21
+ "xlsx": "^0.18.5",
22
  "zustand": "^5.0.12"
23
  },
24
  "devDependencies": {
frontend/src/app/admin/page.tsx CHANGED
@@ -3,9 +3,10 @@
3
  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, ArrowLeft, Settings } from "lucide-react";
8
  import RichTextEditor from "@/components/RichTextEditor";
 
9
 
10
  interface Course {
11
  id: number;
@@ -16,6 +17,8 @@ interface Course {
16
  price: number;
17
  category?: string;
18
  viewCount?: number;
 
 
19
  }
20
 
21
  interface Order {
@@ -30,10 +33,29 @@ interface Order {
30
  export default function AdminDashboard() {
31
  const router = useRouter();
32
  const { user, token } = useAuthStore();
33
- const [activeTab, setActiveTab] = useState<'courses' | 'orders'>('courses');
 
34
  const [courses, setCourses] = useState<Course[]>([]);
35
  const [orders, setOrders] = useState<Order[]>([]);
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  // Course Form
38
  const [title, setTitle] = useState('');
39
  const [description, setDescription] = useState('');
@@ -42,6 +64,8 @@ export default function AdminDashboard() {
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);
@@ -51,14 +75,99 @@ export default function AdminDashboard() {
51
  if (activeTab === 'courses') {
52
  const res = await api.get('/api/courses'); // In real app, might want a specific admin endpoint to see inactive ones too
53
  if (res.success) setCourses(res.data);
54
- } else {
55
  const res = await api.get('/api/admin/orders');
56
  if (res.success) setOrders(res.data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  }
58
  } catch (err) {
59
  console.error(err);
 
60
  }
61
- }, [activeTab]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  useEffect(() => {
64
  if (!token || user?.role !== 'admin') {
@@ -71,14 +180,16 @@ export default function AdminDashboard() {
71
  const handleSubmitCourse = async (e: React.FormEvent) => {
72
  e.preventDefault();
73
  try {
74
- const payload = {
75
- title,
76
- description,
77
- coverImage,
78
- driveLink,
79
- price: Number(price),
80
  category,
81
- viewCount: Number(viewCount) || 0
 
 
82
  };
83
 
84
  let res;
@@ -94,7 +205,7 @@ export default function AdminDashboard() {
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);
@@ -111,6 +222,8 @@ export default function AdminDashboard() {
111
  setPrice(course.price ? course.price.toString() : '');
112
  setCategory(course.category || '');
113
  setViewCount(course.viewCount || 0);
 
 
114
  setShowAddForm(true);
115
  };
116
 
@@ -162,6 +275,20 @@ export default function AdminDashboard() {
162
  <ShoppingCart className="w-5 h-5" />
163
  订单管理
164
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  </nav>
166
  </div>
167
 
@@ -176,7 +303,7 @@ export default function AdminDashboard() {
176
  if (showAddForm) {
177
  setShowAddForm(false);
178
  setEditingCourseId(null);
179
- setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0);
180
  } else {
181
  setShowAddForm(true);
182
  }
@@ -275,6 +402,18 @@ export default function AdminDashboard() {
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>
@@ -284,11 +423,9 @@ export default function AdminDashboard() {
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
 
@@ -388,6 +525,417 @@ export default function AdminDashboard() {
388
  </div>
389
  </div>
390
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  </div>
392
  </div>
393
  );
 
3
  import { useEffect, useState, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
+ import { useAuthStore, useConfigStore } from "@/lib/store";
7
+ import { Plus, LayoutDashboard, ShoppingCart, Settings, BarChart3, Download } from "lucide-react";
8
  import RichTextEditor from "@/components/RichTextEditor";
9
+ import * as XLSX from 'xlsx';
10
 
11
  interface Course {
12
  id: number;
 
17
  price: number;
18
  category?: string;
19
  viewCount?: number;
20
+ likeCount?: number;
21
+ starCount?: number;
22
  }
23
 
24
  interface Order {
 
33
  export default function AdminDashboard() {
34
  const router = useRouter();
35
  const { user, token } = useAuthStore();
36
+ const { uiConfig: globalUiConfig, setUiConfig: setGlobalUiConfig } = useConfigStore();
37
+ const [activeTab, setActiveTab] = useState<'courses' | 'orders' | 'settings' | 'statistics'>('courses');
38
  const [courses, setCourses] = useState<Course[]>([]);
39
  const [orders, setOrders] = useState<Order[]>([]);
40
 
41
+ // Statistics State
42
+ const [statistics, setStatistics] = useState({ vipAmount: 0, donationAmount: 0, purchaseAmount: 0 });
43
+ const [statisticsDetails, setStatisticsDetails] = useState<any[]>([]);
44
+ const [activeStatTab, setActiveStatTab] = useState<'all' | 'vip' | 'donation' | 'purchase'>('all');
45
+ const [startDate, setStartDate] = useState('');
46
+ const [endDate, setEndDate] = useState('');
47
+
48
+ // UI Config Form
49
+ const [uiConfig, setUiConfig] = useState({
50
+ siteName: '',
51
+ logo: '',
52
+ footerText: '',
53
+ heroBackground: '',
54
+ navLinks: [] as { label: string; value: string }[],
55
+ banners: [] as { imageUrl: string; linkUrl: string }[],
56
+ memberFee: 99,
57
+ });
58
+
59
  // Course Form
60
  const [title, setTitle] = useState('');
61
  const [description, setDescription] = useState('');
 
64
  const [price, setPrice] = useState('');
65
  const [category, setCategory] = useState('');
66
  const [viewCount, setViewCount] = useState<number | string>(0);
67
+ const [likeCount, setLikeCount] = useState<number | string>(0);
68
+ const [starCount, setStarCount] = useState<number | string>(0);
69
  const [showAddForm, setShowAddForm] = useState(false);
70
  const [editingCourseId, setEditingCourseId] = useState<number | null>(null);
71
  const [showSettings, setShowSettings] = useState(true);
 
75
  if (activeTab === 'courses') {
76
  const res = await api.get('/api/courses'); // In real app, might want a specific admin endpoint to see inactive ones too
77
  if (res.success) setCourses(res.data);
78
+ } else if (activeTab === 'orders') {
79
  const res = await api.get('/api/admin/orders');
80
  if (res.success) setOrders(res.data);
81
+ } else if (activeTab === 'settings') {
82
+ const res = await api.get('/api/config/ui');
83
+ if (res.success) setUiConfig(res.data);
84
+ } else if (activeTab === 'statistics') {
85
+ let url = '/api/admin/statistics';
86
+ const params = new URLSearchParams();
87
+ if (startDate) params.append('startDate', startDate);
88
+ if (endDate) params.append('endDate', endDate);
89
+ if (params.toString()) url += `?${params.toString()}`;
90
+
91
+ const res = await api.get(url);
92
+ if (res.success) setStatistics(res.data);
93
+
94
+ // Fetch details
95
+ let detailsUrl = '/api/admin/statistics/details';
96
+ const detailsParams = new URLSearchParams();
97
+ detailsParams.append('type', activeStatTab);
98
+ if (startDate) detailsParams.append('startDate', startDate);
99
+ if (endDate) detailsParams.append('endDate', endDate);
100
+ detailsUrl += `?${detailsParams.toString()}`;
101
+
102
+ const detailsRes = await api.get(detailsUrl);
103
+ if (detailsRes.success) setStatisticsDetails(detailsRes.data);
104
+ }
105
+ } catch (err) {
106
+ console.error(err);
107
+ }
108
+ }, [activeTab, startDate, endDate, activeStatTab]);
109
+
110
+ const handleSaveUiConfig = async (e: React.FormEvent) => {
111
+ e.preventDefault();
112
+ try {
113
+ const res = await api.put('/api/config/ui', uiConfig);
114
+ if (res.success) {
115
+ alert('界面设置已保存,立即生效。');
116
+ setUiConfig(res.data);
117
+ setGlobalUiConfig(res.data);
118
  }
119
  } catch (err) {
120
  console.error(err);
121
+ alert('保存设置失败');
122
  }
123
+ };
124
+
125
+ const handleExportExcel = () => {
126
+ if (statisticsDetails.length === 0) {
127
+ alert('没有数据可导出');
128
+ return;
129
+ }
130
+
131
+ const dataToExport = statisticsDetails.map(item => {
132
+ const baseData: any = {
133
+ '时间': new Date(item.createdAt).toLocaleString('zh-CN', {
134
+ year: 'numeric', month: '2-digit', day: '2-digit',
135
+ hour: '2-digit', minute: '2-digit'
136
+ }),
137
+ '订单号': item.orderNo,
138
+ '用户昵称': item.user?.nickname || item.user?.email || '未知用户',
139
+ '用户邮箱': item.user?.email || '-',
140
+ };
141
+
142
+ if (activeStatTab === 'purchase') {
143
+ baseData['分类'] = item.course?.category || '未分类';
144
+ baseData['课程名称'] = item.course?.title || '-';
145
+ }
146
+
147
+ baseData['金额(元)'] = Number(item.amount).toFixed(2);
148
+
149
+ return baseData;
150
+ });
151
+
152
+ const worksheet = XLSX.utils.json_to_sheet(dataToExport);
153
+ const workbook = XLSX.utils.book_new();
154
+
155
+ // Auto-size columns
156
+ const colWidths = Object.keys(dataToExport[0]).map(key => ({ wch: Math.max(key.length * 2, 15) }));
157
+ worksheet['!cols'] = colWidths;
158
+
159
+ const sheetNameMap: Record<string, string> = {
160
+ 'all': '全部明细',
161
+ 'vip': '会员费明细',
162
+ 'donation': '打赏明细',
163
+ 'purchase': '购买明细'
164
+ };
165
+
166
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetNameMap[activeStatTab]);
167
+
168
+ const fileName = `${sheetNameMap[activeStatTab]}_${new Date().toISOString().split('T')[0]}.xlsx`;
169
+ XLSX.writeFile(workbook, fileName);
170
+ };
171
 
172
  useEffect(() => {
173
  if (!token || user?.role !== 'admin') {
 
180
  const handleSubmitCourse = async (e: React.FormEvent) => {
181
  e.preventDefault();
182
  try {
183
+ const payload = {
184
+ title,
185
+ description,
186
+ coverImage,
187
+ driveLink,
188
+ price: Number(price),
189
  category,
190
+ viewCount: Number(viewCount),
191
+ likeCount: Number(likeCount),
192
+ starCount: Number(starCount)
193
  };
194
 
195
  let res;
 
205
  setEditingCourseId(null);
206
  fetchData();
207
  // Reset form
208
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0); setLikeCount(0); setStarCount(0);
209
  }
210
  } catch (err) {
211
  console.error(err);
 
222
  setPrice(course.price ? course.price.toString() : '');
223
  setCategory(course.category || '');
224
  setViewCount(course.viewCount || 0);
225
+ setLikeCount(course.likeCount || 0);
226
+ setStarCount(course.starCount || 0);
227
  setShowAddForm(true);
228
  };
229
 
 
275
  <ShoppingCart className="w-5 h-5" />
276
  订单管理
277
  </button>
278
+ <button
279
+ onClick={() => setActiveTab('settings')}
280
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'settings' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
281
+ >
282
+ <Settings className="w-5 h-5" />
283
+ 界面设置
284
+ </button>
285
+ <button
286
+ onClick={() => setActiveTab('statistics')}
287
+ className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-colors ${activeTab === 'statistics' ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-600 hover:bg-gray-50'}`}
288
+ >
289
+ <BarChart3 className="w-5 h-5" />
290
+ 数据统计
291
+ </button>
292
  </nav>
293
  </div>
294
 
 
303
  if (showAddForm) {
304
  setShowAddForm(false);
305
  setEditingCourseId(null);
306
+ setTitle(''); setDescription(''); setCoverImage(''); setDriveLink(''); setPrice(''); setCategory(''); setViewCount(0); setLikeCount(0); setStarCount(0);
307
  } else {
308
  setShowAddForm(true);
309
  }
 
402
  <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" />
403
  </div>
404
 
405
+ {/* Like Count */}
406
+ <div>
407
+ <label className="block text-sm font-medium text-gray-700 mb-2">点赞人数设置</label>
408
+ <input type="number" value={likeCount} onChange={e => setLikeCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
409
+ </div>
410
+
411
+ {/* Star Count */}
412
+ <div>
413
+ <label className="block text-sm font-medium text-gray-700 mb-2">收藏人数设置</label>
414
+ <input type="number" value={starCount} onChange={e => setStarCount(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white" placeholder="0" />
415
+ </div>
416
+
417
  {/* Category */}
418
  <div>
419
  <label className="block text-sm font-medium text-gray-700 mb-2">分类</label>
 
423
  className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white"
424
  >
425
  <option value="">请选择分类</option>
426
+ {globalUiConfig?.navLinks?.map(link => (
427
+ <option key={link.value} value={link.label}>{link.label}</option>
428
+ ))}
 
 
429
  </select>
430
  </div>
431
 
 
525
  </div>
526
  </div>
527
  )}
528
+ {activeTab === 'settings' && (
529
+ <div>
530
+ <h2 className="text-2xl font-bold text-gray-900 mb-8">界面设置</h2>
531
+ <form onSubmit={handleSaveUiConfig} className="max-w-2xl space-y-6">
532
+ <div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
533
+ <h3 className="font-semibold text-gray-900">基础信息</h3>
534
+ <div>
535
+ <label className="block text-sm font-medium text-gray-700 mb-1">站点名称</label>
536
+ <input
537
+ type="text"
538
+ value={uiConfig.siteName}
539
+ onChange={e => setUiConfig({ ...uiConfig, siteName: e.target.value })}
540
+ className="w-full px-4 py-2 rounded-lg border border-gray-200"
541
+ required
542
+ />
543
+ </div>
544
+ <div>
545
+ <label className="block text-sm font-medium text-gray-700 mb-1">Logo URL (可选)</label>
546
+ <input
547
+ type="text"
548
+ value={uiConfig.logo}
549
+ onChange={e => setUiConfig({ ...uiConfig, logo: e.target.value })}
550
+ className="w-full px-4 py-2 rounded-lg border border-gray-200"
551
+ placeholder="输入Logo图片URL,若不填则显示站点名称"
552
+ />
553
+ </div>
554
+ <div>
555
+ <label className="block text-sm font-medium text-gray-700 mb-1">顶部背景图 URL (可选)</label>
556
+ <div className="space-y-2">
557
+ {uiConfig.heroBackground && (
558
+ <div className="relative w-full max-w-sm aspect-[3/1] rounded-lg overflow-hidden border border-gray-200">
559
+ <img src={uiConfig.heroBackground} alt="Hero Background" className="w-full h-full object-cover" />
560
+ <button type="button" onClick={() => setUiConfig({ ...uiConfig, heroBackground: '' })} className="absolute top-2 right-2 bg-black/50 text-white rounded-full p-1 hover:bg-black/70">
561
+ &times;
562
+ </button>
563
+ </div>
564
+ )}
565
+ <div className="flex gap-2">
566
+ <input
567
+ type="text"
568
+ value={uiConfig.heroBackground || ''}
569
+ onChange={e => setUiConfig({ ...uiConfig, heroBackground: e.target.value })}
570
+ className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
571
+ placeholder="输入图片URL,或使用右侧按钮上传"
572
+ />
573
+ <label className="cursor-pointer px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors flex items-center">
574
+ 上传
575
+ <input
576
+ type="file"
577
+ accept="image/*"
578
+ className="hidden"
579
+ onChange={async (e) => {
580
+ const file = e.target.files?.[0];
581
+ if (!file) return;
582
+ const formData = new FormData();
583
+ formData.append('file', file);
584
+ try {
585
+ const res = await api.post('/api/upload', formData, {
586
+ headers: { 'Content-Type': 'multipart/form-data' }
587
+ });
588
+ if (res.success) setUiConfig({ ...uiConfig, heroBackground: res.url });
589
+ else alert('上传失败');
590
+ } catch (err) {
591
+ console.error(err);
592
+ alert('上传失败');
593
+ }
594
+ }}
595
+ />
596
+ </label>
597
+ </div>
598
+ </div>
599
+ </div>
600
+ <div>
601
+ <label className="block text-sm font-medium text-gray-700 mb-1">底部版权文案</label>
602
+ <input
603
+ type="text"
604
+ value={uiConfig.footerText}
605
+ onChange={e => setUiConfig({ ...uiConfig, footerText: e.target.value })}
606
+ className="w-full px-4 py-2 rounded-lg border border-gray-200"
607
+ required
608
+ />
609
+ </div>
610
+ <div>
611
+ <label className="block text-sm font-medium text-gray-700 mb-1">会员收费 (¥)</label>
612
+ <input
613
+ type="number"
614
+ value={uiConfig.memberFee ?? 99}
615
+ onChange={e => setUiConfig({ ...uiConfig, memberFee: Number(e.target.value) })}
616
+ className="w-full px-4 py-2 rounded-lg border border-gray-200"
617
+ min="0"
618
+ step="0.01"
619
+ required
620
+ />
621
+ </div>
622
+ </div>
623
+
624
+ <div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
625
+ <div className="flex justify-between items-center">
626
+ <h3 className="font-semibold text-gray-900">导航栏与分类管理</h3>
627
+ <button
628
+ type="button"
629
+ onClick={() => setUiConfig({
630
+ ...uiConfig,
631
+ navLinks: [...(uiConfig.navLinks || []), { label: '新分类', value: 'new-category' }]
632
+ })}
633
+ className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
634
+ >
635
+ <Plus className="w-4 h-4" /> 添加分类
636
+ </button>
637
+ </div>
638
+ <div className="space-y-3">
639
+ {(uiConfig.navLinks || []).map((link, idx) => (
640
+ <div key={idx} className="flex gap-3 items-center">
641
+ <input
642
+ type="text"
643
+ value={link.label}
644
+ onChange={e => {
645
+ const newLinks = [...uiConfig.navLinks];
646
+ newLinks[idx].label = e.target.value;
647
+ setUiConfig({ ...uiConfig, navLinks: newLinks });
648
+ }}
649
+ className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
650
+ placeholder="显示名称 (如: AI新资讯)"
651
+ required
652
+ />
653
+ <input
654
+ type="text"
655
+ value={link.value}
656
+ onChange={e => {
657
+ const newLinks = [...uiConfig.navLinks];
658
+ newLinks[idx].value = e.target.value;
659
+ setUiConfig({ ...uiConfig, navLinks: newLinks });
660
+ }}
661
+ className="flex-1 px-4 py-2 rounded-lg border border-gray-200"
662
+ placeholder="路由值 (如: ai-news)"
663
+ required
664
+ />
665
+ <button
666
+ type="button"
667
+ onClick={() => {
668
+ const newLinks = [...uiConfig.navLinks];
669
+ newLinks.splice(idx, 1);
670
+ setUiConfig({ ...uiConfig, navLinks: newLinks });
671
+ }}
672
+ className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
673
+ >
674
+ 删除
675
+ </button>
676
+ </div>
677
+ ))}
678
+ </div>
679
+ </div>
680
+
681
+ <div className="space-y-4 bg-gray-50 p-6 rounded-xl border border-gray-100">
682
+ <div className="flex justify-between items-center">
683
+ <h3 className="font-semibold text-gray-900">广告图管理</h3>
684
+ <button
685
+ type="button"
686
+ onClick={() => setUiConfig({
687
+ ...uiConfig,
688
+ banners: [...(uiConfig.banners || []), { imageUrl: '', linkUrl: '' }]
689
+ })}
690
+ className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
691
+ >
692
+ <Plus className="w-4 h-4" /> 添加广告图
693
+ </button>
694
+ </div>
695
+ <div className="space-y-4">
696
+ {(uiConfig.banners || []).map((banner, idx) => (
697
+ <div key={idx} className="flex flex-col md:flex-row gap-3 items-start md:items-center bg-white p-4 rounded-lg border border-gray-200">
698
+ <div className="flex-1 w-full space-y-3">
699
+ <div className="flex gap-2 items-center">
700
+ {banner.imageUrl && (
701
+ <img src={banner.imageUrl} alt="banner preview" className="w-16 h-10 object-cover rounded" />
702
+ )}
703
+ <input
704
+ type="text"
705
+ value={banner.imageUrl}
706
+ onChange={e => {
707
+ const newBanners = [...(uiConfig.banners || [])];
708
+ newBanners[idx].imageUrl = e.target.value;
709
+ setUiConfig({ ...uiConfig, banners: newBanners });
710
+ }}
711
+ className="flex-1 px-3 py-2 rounded-md border border-gray-200 text-sm"
712
+ placeholder="图片 URL (或右侧上传)"
713
+ required
714
+ />
715
+ <label className="cursor-pointer px-3 py-2 bg-gray-100 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-200 transition-colors">
716
+ 上传
717
+ <input
718
+ type="file"
719
+ accept="image/*"
720
+ className="hidden"
721
+ onChange={async (e) => {
722
+ const file = e.target.files?.[0];
723
+ if (!file) return;
724
+ const formData = new FormData();
725
+ formData.append('file', file);
726
+ try {
727
+ const res = await api.post('/api/upload', formData, {
728
+ headers: { 'Content-Type': 'multipart/form-data' }
729
+ });
730
+ if (res.success) {
731
+ const newBanners = [...(uiConfig.banners || [])];
732
+ newBanners[idx].imageUrl = res.url;
733
+ setUiConfig({ ...uiConfig, banners: newBanners });
734
+ } else alert('上传失败');
735
+ } catch (err) {
736
+ console.error(err);
737
+ alert('上传失败');
738
+ }
739
+ }}
740
+ />
741
+ </label>
742
+ </div>
743
+ <input
744
+ type="text"
745
+ value={banner.linkUrl}
746
+ onChange={e => {
747
+ const newBanners = [...(uiConfig.banners || [])];
748
+ newBanners[idx].linkUrl = e.target.value;
749
+ setUiConfig({ ...uiConfig, banners: newBanners });
750
+ }}
751
+ className="w-full px-3 py-2 rounded-md border border-gray-200 text-sm"
752
+ placeholder="跳转链接 URL"
753
+ />
754
+ </div>
755
+ <button
756
+ type="button"
757
+ onClick={() => {
758
+ const newBanners = [...(uiConfig.banners || [])];
759
+ newBanners.splice(idx, 1);
760
+ setUiConfig({ ...uiConfig, banners: newBanners });
761
+ }}
762
+ className="p-2 text-red-500 hover:bg-red-50 rounded-lg shrink-0 self-end md:self-auto"
763
+ >
764
+ 删除
765
+ </button>
766
+ </div>
767
+ ))}
768
+ {(!uiConfig.banners || uiConfig.banners.length === 0) && (
769
+ <div className="text-center py-4 text-gray-500 text-sm border-2 border-dashed border-gray-200 rounded-lg">
770
+ 暂无广告图,点击右上角添加
771
+ </div>
772
+ )}
773
+ </div>
774
+ </div>
775
+
776
+ <div className="pt-4">
777
+ <button type="submit" className="px-6 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors">
778
+ 保存设置
779
+ </button>
780
+ </div>
781
+ </form>
782
+ </div>
783
+ )}
784
+
785
+ {activeTab === 'statistics' && (
786
+ <div>
787
+ <h2 className="text-2xl font-bold text-gray-900 mb-8">数据统计</h2>
788
+
789
+ <div className="flex flex-wrap gap-4 mb-8 items-end">
790
+ <div>
791
+ <label className="block text-sm font-medium text-gray-700 mb-1">开始日期</label>
792
+ <input
793
+ type="date"
794
+ value={startDate}
795
+ onChange={e => setStartDate(e.target.value)}
796
+ className="px-4 py-2 rounded-lg border border-gray-200"
797
+ />
798
+ </div>
799
+ <div>
800
+ <label className="block text-sm font-medium text-gray-700 mb-1">结束日期</label>
801
+ <input
802
+ type="date"
803
+ value={endDate}
804
+ onChange={e => setEndDate(e.target.value)}
805
+ className="px-4 py-2 rounded-lg border border-gray-200"
806
+ />
807
+ </div>
808
+ <button
809
+ onClick={fetchData}
810
+ className="px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
811
+ >
812
+ 查询
813
+ </button>
814
+ </div>
815
+
816
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
817
+ <div
818
+ className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'all' ? 'bg-blue-100 border-blue-300 ring-2 ring-blue-500/20' : 'bg-blue-50 border-blue-100 hover:bg-blue-100'}`}
819
+ onClick={() => setActiveStatTab('all')}
820
+ >
821
+ <div className="text-blue-600 text-sm font-medium mb-2">总计收入</div>
822
+ <div className="text-3xl font-bold text-gray-900">
823
+ ¥{(statistics.vipAmount + statistics.donationAmount + statistics.purchaseAmount).toFixed(2)}
824
+ </div>
825
+ </div>
826
+
827
+ <div
828
+ className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'vip' ? 'bg-yellow-100 border-yellow-300 ring-2 ring-yellow-500/20' : 'bg-yellow-50 border-yellow-100 hover:bg-yellow-100'}`}
829
+ onClick={() => setActiveStatTab('vip')}
830
+ >
831
+ <div className="text-yellow-600 text-sm font-medium mb-2">会员费统计</div>
832
+ <div className="text-2xl font-bold text-gray-900">¥{statistics.vipAmount.toFixed(2)}</div>
833
+ </div>
834
+
835
+ <div
836
+ className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'donation' ? 'bg-green-100 border-green-300 ring-2 ring-green-500/20' : 'bg-green-50 border-green-100 hover:bg-green-100'}`}
837
+ onClick={() => setActiveStatTab('donation')}
838
+ >
839
+ <div className="text-green-600 text-sm font-medium mb-2">打赏统计</div>
840
+ <div className="text-2xl font-bold text-gray-900">¥{statistics.donationAmount.toFixed(2)}</div>
841
+ </div>
842
+
843
+ <div
844
+ className={`p-6 rounded-2xl border cursor-pointer transition-all ${activeStatTab === 'purchase' ? 'bg-purple-100 border-purple-300 ring-2 ring-purple-500/20' : 'bg-purple-50 border-purple-100 hover:bg-purple-100'}`}
845
+ onClick={() => setActiveStatTab('purchase')}
846
+ >
847
+ <div className="text-purple-600 text-sm font-medium mb-2">购买统计</div>
848
+ <div className="text-2xl font-bold text-gray-900">¥{statistics.purchaseAmount.toFixed(2)}</div>
849
+ </div>
850
+ </div>
851
+
852
+ <div className="bg-white border border-gray-100 rounded-2xl overflow-hidden shadow-sm">
853
+ <div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
854
+ <h3 className="font-semibold text-gray-900">
855
+ {activeStatTab === 'all' && '全部明细'}
856
+ {activeStatTab === 'vip' && '会员费明细'}
857
+ {activeStatTab === 'donation' && '打赏明细'}
858
+ {activeStatTab === 'purchase' && '购买明细'}
859
+ </h3>
860
+ <button
861
+ onClick={handleExportExcel}
862
+ disabled={statisticsDetails.length === 0}
863
+ className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
864
+ >
865
+ <Download className="w-4 h-4" /> 导出 Excel
866
+ </button>
867
+ </div>
868
+ <div className="overflow-x-auto">
869
+ <table className="w-full text-left border-collapse border border-gray-200">
870
+ <thead>
871
+ <tr className="bg-gray-50">
872
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">时间</th>
873
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">订单号</th>
874
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">用户</th>
875
+ {activeStatTab === 'purchase' && (
876
+ <>
877
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">分类</th>
878
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700">课程名称</th>
879
+ </>
880
+ )}
881
+ <th className="py-3 px-6 font-medium border border-gray-200 text-sm text-gray-700 text-right">金额</th>
882
+ </tr>
883
+ </thead>
884
+ <tbody className="bg-white">
885
+ {statisticsDetails.map((item, idx) => (
886
+ <tr key={idx} className="text-sm hover:bg-gray-50/50 transition-colors">
887
+ <td className="py-4 px-6 text-gray-500 border border-gray-200">
888
+ {new Date(item.createdAt).toLocaleString('zh-CN', {
889
+ year: 'numeric', month: '2-digit', day: '2-digit',
890
+ hour: '2-digit', minute: '2-digit'
891
+ })}
892
+ </td>
893
+ <td className="py-4 px-6 text-gray-500 font-mono text-xs border border-gray-200">{item.orderNo}</td>
894
+ <td className="py-4 px-6 border border-gray-200">
895
+ <div className="flex items-center gap-2">
896
+ <div className="w-6 h-6 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center font-bold text-xs shrink-0">
897
+ {item.user?.nickname?.charAt(0).toUpperCase() || 'U'}
898
+ </div>
899
+ <span className="font-medium text-gray-900 truncate max-w-[120px]" title={item.user?.nickname || item.user?.email}>
900
+ {item.user?.nickname || item.user?.email || '未知用户'}
901
+ </span>
902
+ </div>
903
+ </td>
904
+ {activeStatTab === 'purchase' && (
905
+ <>
906
+ <td className="py-4 px-6 border border-gray-200">
907
+ <span className="px-2 py-1 bg-gray-100 text-gray-600 rounded-md text-xs font-medium whitespace-nowrap">
908
+ {item.course?.category || '未分类'}
909
+ </span>
910
+ </td>
911
+ <td className="py-4 px-6 text-gray-900 font-medium max-w-[200px] truncate border border-gray-200" title={item.course?.title || '-'}>
912
+ {item.course?.title || '-'}
913
+ </td>
914
+ </>
915
+ )}
916
+ <td className="py-4 px-6 font-semibold text-gray-900 text-right border border-gray-200">
917
+ ¥{Number(item.amount).toFixed(2)}
918
+ </td>
919
+ </tr>
920
+ ))}
921
+ {statisticsDetails.length === 0 && (
922
+ <tr>
923
+ <td colSpan={activeStatTab === 'purchase' ? 6 : 4} className="py-12 text-center text-gray-500 border border-gray-200">
924
+ <div className="flex flex-col items-center justify-center">
925
+ <div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-3">
926
+ <span className="text-2xl text-gray-300">📊</span>
927
+ </div>
928
+ <p>该时间段内暂无数据</p>
929
+ </div>
930
+ </td>
931
+ </tr>
932
+ )}
933
+ </tbody>
934
+ </table>
935
+ </div>
936
+ </div>
937
+ </div>
938
+ )}
939
  </div>
940
  </div>
941
  );
frontend/src/app/client-layout.tsx CHANGED
@@ -1,30 +1,131 @@
1
  "use client";
2
 
3
  import Link from "next/link";
4
- import { useAuthStore } from "@/lib/store";
5
  import { useRouter } from "next/navigation";
 
 
6
 
7
  export default function ClientLayout({ children }: { children: React.ReactNode }) {
8
  const { user, logout } = useAuthStore();
 
9
  const router = useRouter();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  const handleLogout = () => {
12
  logout();
13
  router.push('/');
14
  };
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  return (
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
  <>
@@ -37,14 +138,57 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
37
  {user.role === 'admin' && (
38
  <Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
39
  )}
40
- <div className="flex items-center gap-4 border-l pl-6 ml-2">
41
- <span className="text-sm font-medium text-gray-900">{user.nickname}</span>
42
- <button
43
- onClick={handleLogout}
44
- className="text-sm text-gray-500 hover:text-gray-900"
45
  >
46
- 退出登录
47
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  </div>
49
  </>
50
  ) : (
@@ -62,7 +206,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
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
  </>
 
1
  "use client";
2
 
3
  import Link from "next/link";
4
+ import { useAuthStore, useConfigStore } from "@/lib/store";
5
  import { useRouter } from "next/navigation";
6
+ import { useEffect, useState } from "react";
7
+ import { api } from "@/lib/api";
8
 
9
  export default function ClientLayout({ children }: { children: React.ReactNode }) {
10
  const { user, logout } = useAuthStore();
11
+ const { uiConfig, setUiConfig } = useConfigStore();
12
  const router = useRouter();
13
+ const [isInitializing, setIsInitializing] = useState(true);
14
+ const [dropdownOpen, setDropdownOpen] = useState(false);
15
+ const { setAuth, token } = useAuthStore();
16
+
17
+ useEffect(() => {
18
+ let isMounted = true;
19
+
20
+ const initApp = async () => {
21
+ try {
22
+ const configPromise = api.get('/api/config/ui').catch(e => {
23
+ console.error('Failed to fetch UI config:', e);
24
+ return null;
25
+ });
26
+
27
+ const authPromise = (async () => {
28
+ const storedToken = localStorage.getItem('token');
29
+ if (storedToken && !user) {
30
+ try {
31
+ const res = await api.get('/api/auth/profile');
32
+ if (res.success && isMounted) {
33
+ setAuth(res.data, storedToken);
34
+ }
35
+ } catch (err) {
36
+ console.error('Failed to restore auth:', err);
37
+ localStorage.removeItem('token');
38
+ }
39
+ }
40
+ })();
41
+
42
+ const [configRes] = await Promise.all([configPromise, authPromise]);
43
+
44
+ if (isMounted && configRes && configRes.success && configRes.data) {
45
+ setUiConfig(configRes.data);
46
+ }
47
+ } finally {
48
+ if (isMounted) {
49
+ setIsInitializing(false);
50
+ }
51
+ }
52
+ };
53
+
54
+ if (!uiConfig?.siteName || !user) {
55
+ initApp();
56
+ } else {
57
+ setIsInitializing(false);
58
+ }
59
+
60
+ return () => {
61
+ isMounted = false;
62
+ };
63
+ }, [setUiConfig, setAuth, uiConfig?.siteName, user]);
64
 
65
  const handleLogout = () => {
66
  logout();
67
  router.push('/');
68
  };
69
 
70
+ const handleUpgradeVip = async () => {
71
+ try {
72
+ const res = await api.post('/api/orders/create', { isVip: true });
73
+ if (res.success) {
74
+ router.push(`/payment/${res.data.orderId}`);
75
+ }
76
+ } catch (err) {
77
+ console.error(err);
78
+ alert('创建会员订单失败,请稍后重试');
79
+ }
80
+ };
81
+
82
+ const getAvatarColor = (name: string) => {
83
+ let hash = 0;
84
+ for (let i = 0; i < name.length; i++) {
85
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
86
+ }
87
+ const hue = Math.abs(hash % 360);
88
+ return `hsl(${hue}, 70%, 50%)`;
89
+ };
90
+
91
+ if (isInitializing) {
92
+ return (
93
+ <div className="min-h-screen flex items-center justify-center bg-gray-50">
94
+ <div className="flex flex-col items-center gap-3">
95
+ <div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
96
+ <p className="text-gray-500 font-medium">应用加载中...</p>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const siteName = uiConfig?.siteName || '极简AI';
103
+ const footerText = uiConfig?.footerText || `© ${new Date().getFullYear()} 极简AI. 保留所有权利。`;
104
+ const navLinks = uiConfig?.navLinks || [];
105
+
106
  return (
107
  <>
108
  <header className="bg-white border-b sticky top-0 z-50">
109
  <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
110
+ <Link href="/" className="text-xl font-bold text-blue-600">
111
+ {uiConfig?.logo ? (
112
+ // eslint-disable-next-line @next/next/no-img-element
113
+ <img src={uiConfig.logo} alt={siteName} className="h-8 object-contain" />
114
+ ) : (
115
+ siteName
116
+ )}
117
+ </Link>
118
  <nav className="flex items-center gap-6">
119
  <Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">首页</Link>
120
+ {navLinks.map((link, idx) => (
121
+ <Link
122
+ key={idx}
123
+ href={`/?category=${link.value}`}
124
+ className="text-gray-600 hover:text-blue-600 font-medium"
125
+ >
126
+ {link.label}
127
+ </Link>
128
+ ))}
129
 
130
  {user ? (
131
  <>
 
138
  {user.role === 'admin' && (
139
  <Link href="/admin" className="text-gray-600 hover:text-blue-600 font-medium">管理后台</Link>
140
  )}
141
+ <div className="relative border-l pl-6 ml-2">
142
+ <div
143
+ className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold cursor-pointer hover:opacity-90 transition-opacity select-none relative"
144
+ style={{ backgroundColor: getAvatarColor(user.nickname) }}
145
+ onClick={() => setDropdownOpen(!dropdownOpen)}
146
  >
147
+ {user.nickname.charAt(0).toUpperCase()}
148
+ {user.isVip && (
149
+ <div className="absolute -bottom-1 -right-1 bg-yellow-400 text-xs text-white px-1.5 rounded-full border-2 border-white shadow-sm font-bold scale-75">
150
+ VIP
151
+ </div>
152
+ )}
153
+ </div>
154
+
155
+ {dropdownOpen && (
156
+ <>
157
+ <div
158
+ className="fixed inset-0 z-40"
159
+ onClick={() => setDropdownOpen(false)}
160
+ ></div>
161
+ <div className="absolute right-0 mt-3 w-48 bg-white rounded-xl shadow-lg border border-gray-100 py-2 z-50 overflow-hidden">
162
+ <div className="px-4 py-2 border-b border-gray-50 mb-1">
163
+ <div className="text-sm font-medium text-gray-900 truncate">{user.nickname}</div>
164
+ <div className="text-xs text-gray-500 truncate">{user.email}</div>
165
+ </div>
166
+
167
+ {!user.isVip && (
168
+ <button
169
+ onClick={() => {
170
+ setDropdownOpen(false);
171
+ handleUpgradeVip();
172
+ }}
173
+ className="w-full text-left px-4 py-2.5 text-sm text-yellow-600 hover:bg-yellow-50 font-medium flex items-center transition-colors"
174
+ >
175
+ <span className="mr-2">👑</span>
176
+ 注册会员 (¥{uiConfig?.memberFee ?? 99})
177
+ </button>
178
+ )}
179
+
180
+ <button
181
+ onClick={() => {
182
+ setDropdownOpen(false);
183
+ handleLogout();
184
+ }}
185
+ className="w-full text-left px-4 py-2.5 text-sm text-gray-600 hover:bg-gray-50 transition-colors"
186
+ >
187
+ 退出登录
188
+ </button>
189
+ </div>
190
+ </>
191
+ )}
192
  </div>
193
  </>
194
  ) : (
 
206
 
207
  <footer className="bg-white border-t py-8 mt-auto">
208
  <div className="max-w-7xl mx-auto px-4 text-center text-gray-500 text-sm">
209
+ {footerText}
210
  </div>
211
  </footer>
212
  </>
frontend/src/app/course/[id]/comments.tsx ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { api } from "@/lib/api";
5
+ import { useAuthStore } from "@/lib/store";
6
+
7
+ interface Comment {
8
+ id: number;
9
+ courseId: number;
10
+ userId: number;
11
+ parentId: number | null;
12
+ replyToCommentId: number | null;
13
+ content: string;
14
+ createdAt: string;
15
+ user: {
16
+ id: number;
17
+ nickname: string;
18
+ avatar: string | null;
19
+ isVip: boolean;
20
+ };
21
+ replyToUser?: {
22
+ id: number;
23
+ nickname: string;
24
+ };
25
+ }
26
+
27
+ export default function Comments({ courseId }: { courseId: number }) {
28
+ const [comments, setComments] = useState<Comment[]>([]);
29
+ const [newComment, setNewComment] = useState("");
30
+ const [replyContent, setReplyContent] = useState("");
31
+ const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
32
+ const [loading, setLoading] = useState(true);
33
+ const { user } = useAuthStore();
34
+ const [isPosting, setIsPosting] = useState(false);
35
+
36
+ useEffect(() => {
37
+ fetchComments();
38
+ }, [courseId]);
39
+
40
+ const fetchComments = async () => {
41
+ try {
42
+ const res = await api.get(`/api/courses/${courseId}/comments`);
43
+ if (res.success) {
44
+ setComments(res.data);
45
+ }
46
+ } catch (err) {
47
+ console.error(err);
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ };
52
+
53
+ const handleSubmit = async (targetComment: Comment | null = null) => {
54
+ if (!user) {
55
+ alert("请先登录");
56
+ return;
57
+ }
58
+
59
+ const content = targetComment ? replyContent : newComment;
60
+ if (!content.trim()) return;
61
+
62
+ setIsPosting(true);
63
+ try {
64
+ let parentId = null;
65
+ let replyToCommentId = null;
66
+
67
+ if (targetComment) {
68
+ parentId = targetComment.parentId || targetComment.id;
69
+ replyToCommentId = targetComment.id;
70
+ }
71
+
72
+ const res = await api.post(`/api/courses/${courseId}/comments`, {
73
+ content,
74
+ parentId,
75
+ replyToCommentId,
76
+ });
77
+
78
+ if (res.success) {
79
+ setComments((prev) => [...prev, res.data]);
80
+ if (targetComment) {
81
+ setReplyContent("");
82
+ setReplyingTo(null);
83
+ } else {
84
+ setNewComment("");
85
+ }
86
+ }
87
+ } catch (err) {
88
+ console.error(err);
89
+ alert("发送评论失败");
90
+ } finally {
91
+ setIsPosting(false);
92
+ }
93
+ };
94
+
95
+ const getAvatarColor = (name: string) => {
96
+ let hash = 0;
97
+ for (let i = 0; i < name.length; i++) {
98
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
99
+ }
100
+ const hue = Math.abs(hash % 360);
101
+ return `hsl(${hue}, 70%, 50%)`;
102
+ };
103
+
104
+ const renderComment = (comment: Comment, isReply = false) => {
105
+ return (
106
+ <div key={comment.id} className={`flex gap-3 sm:gap-4 ${isReply ? 'mt-4' : 'mt-6'}`}>
107
+ <div
108
+ className={`${isReply ? 'w-8 h-8 text-sm' : 'w-10 h-10'} rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold select-none relative`}
109
+ style={{ backgroundColor: getAvatarColor(comment.user.nickname) }}
110
+ >
111
+ {comment.user.nickname.charAt(0).toUpperCase()}
112
+ {comment.user.isVip && (
113
+ <div className={`absolute ${isReply ? '-bottom-1 -right-1 scale-50' : '-bottom-1 -right-1 scale-75'} bg-yellow-400 text-[10px] text-white px-1 rounded-full border-2 border-white font-bold`}>
114
+ VIP
115
+ </div>
116
+ )}
117
+ </div>
118
+ <div className="flex-1 min-w-0">
119
+ <div className="flex items-center gap-2 mb-1">
120
+ <span className="font-medium text-gray-400 truncate">{comment.user.nickname}</span>
121
+ <span className="text-xs sm:text-sm text-gray-500 whitespace-nowrap">
122
+ {new Date(comment.createdAt).toLocaleString()}
123
+ </span>
124
+ </div>
125
+ <p className="text-sm sm:text-base text-black whitespace-pre-wrap break-words">
126
+ {isReply && comment.replyToUser && comment.replyToCommentId !== comment.parentId && (
127
+ <span className="text-gray-500 mr-2">回复 <span className="text-gray-400">@{comment.replyToUser.nickname}</span>:</span>
128
+ )}
129
+ {comment.content}
130
+ </p>
131
+
132
+ {user && (
133
+ <div className="mt-2">
134
+ <button
135
+ onClick={() => {
136
+ setReplyingTo(replyingTo?.id === comment.id ? null : comment);
137
+ setReplyContent("");
138
+ }}
139
+ className="text-sm text-gray-400 hover:text-gray-600 font-medium"
140
+ >
141
+ 回复
142
+ </button>
143
+ </div>
144
+ )}
145
+
146
+ {replyingTo?.id === comment.id && (
147
+ <div className="mt-3 sm:mt-4 flex flex-col sm:flex-row gap-2 sm:gap-3">
148
+ <input
149
+ type="text"
150
+ value={replyContent}
151
+ onChange={(e) => setReplyContent(e.target.value)}
152
+ placeholder={`回复 ${comment.user.nickname}...`}
153
+ className="flex-1 px-3 py-2 sm:px-4 sm:py-2 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none text-sm sm:text-base"
154
+ onKeyDown={(e) => {
155
+ if (e.key === 'Enter') {
156
+ handleSubmit(comment);
157
+ }
158
+ }}
159
+ autoFocus
160
+ />
161
+ <div className="flex justify-end gap-2">
162
+ <button
163
+ onClick={() => {
164
+ setReplyingTo(null);
165
+ setReplyContent("");
166
+ }}
167
+ className="px-4 py-2 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors font-medium text-sm sm:text-base"
168
+ >
169
+ 取消
170
+ </button>
171
+ <button
172
+ onClick={() => handleSubmit(comment)}
173
+ disabled={!replyContent.trim() || isPosting}
174
+ className="px-4 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm sm:text-base"
175
+ >
176
+ {isPosting ? '发送中...' : '发送'}
177
+ </button>
178
+ </div>
179
+ </div>
180
+ )}
181
+
182
+ {/* Render replies */}
183
+ {!isReply && (
184
+ <div className="mt-4 space-y-4">
185
+ {comments
186
+ .filter((c) => c.parentId === comment.id)
187
+ .map((reply) => renderComment(reply, true))}
188
+ </div>
189
+ )}
190
+ </div>
191
+ </div>
192
+ );
193
+ };
194
+
195
+ const topLevelComments = comments.filter((c) => !c.parentId);
196
+
197
+ return (
198
+ <div className="mt-8 sm:mt-12 bg-white rounded-2xl shadow-sm border border-gray-100 p-4 sm:p-8">
199
+ <h3 className="text-lg sm:text-xl font-bold text-gray-900 mb-4 sm:mb-6">评论区 ({comments.length})</h3>
200
+
201
+ <div className="flex gap-3 sm:gap-4 mb-6 sm:mb-8">
202
+ {user ? (
203
+ <div
204
+ className="w-10 h-10 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold select-none relative"
205
+ style={{ backgroundColor: getAvatarColor(user.nickname) }}
206
+ >
207
+ {user.nickname.charAt(0).toUpperCase()}
208
+ {user.isVip && (
209
+ <div className="absolute -bottom-1 -right-1 bg-yellow-400 text-[10px] text-white px-1 rounded-full border-2 border-white font-bold scale-75">
210
+ VIP
211
+ </div>
212
+ )}
213
+ </div>
214
+ ) : (
215
+ <div className="w-10 h-10 sm:w-12 sm:h-12 rounded-full flex-shrink-0 bg-gray-100 flex items-center justify-center text-gray-400">
216
+ ?
217
+ </div>
218
+ )}
219
+ <div className="flex-1 min-w-0">
220
+ <textarea
221
+ value={newComment}
222
+ onChange={(e) => setNewComment(e.target.value)}
223
+ placeholder={user ? "写下你的评论..." : "请先登录后发表评论"}
224
+ className="w-full px-3 py-2 sm:px-4 sm:py-3 rounded-xl border border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none min-h-[80px] sm:min-h-[100px] resize-y text-sm sm:text-base"
225
+ disabled={!user}
226
+ />
227
+ <div className="mt-2 sm:mt-3 flex justify-end">
228
+ <button
229
+ onClick={() => handleSubmit()}
230
+ disabled={!user || !newComment.trim() || isPosting}
231
+ className="px-4 py-2 sm:px-6 sm:py-2.5 bg-blue-600 text-white rounded-xl hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed font-medium text-sm sm:text-base"
232
+ >
233
+ {isPosting ? '发表中...' : '发表评论'}
234
+ </button>
235
+ </div>
236
+ </div>
237
+ </div>
238
+
239
+ <div className="space-y-4 sm:space-y-6 divide-y divide-gray-100">
240
+ {loading ? (
241
+ <div className="text-center text-gray-500 py-8">加载评论中...</div>
242
+ ) : topLevelComments.length > 0 ? (
243
+ topLevelComments.map((comment) => renderComment(comment))
244
+ ) : (
245
+ <div className="text-center text-gray-500 py-8">暂无评论,来抢沙发吧!</div>
246
+ )}
247
+ </div>
248
+ </div>
249
+ );
250
+ }
frontend/src/app/course/[id]/page.tsx CHANGED
@@ -3,9 +3,10 @@
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
 
10
  export default function CourseDetail() {
11
  const { id } = useParams();
@@ -19,8 +20,14 @@ export default function CourseDetail() {
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 () => {
26
  try {
@@ -135,6 +142,38 @@ export default function CourseDetail() {
135
  }
136
  };
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  if (loading) {
139
  return <div className="animate-pulse h-96 bg-white rounded-xl"></div>;
140
  }
@@ -188,7 +227,7 @@ export default function CourseDetail() {
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'}`}
@@ -203,6 +242,14 @@ export default function CourseDetail() {
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 */}
@@ -216,11 +263,10 @@ export default function CourseDetail() {
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
  ) : (
@@ -236,56 +282,108 @@ export default function CourseDetail() {
236
  </div>
237
  </div>
238
 
239
- {/* 资料链接弹窗 */}
240
- {selectedLink && (
241
- <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
242
- <div className="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
243
- <div className="flex justify-between items-center mb-6">
244
- <h3 className="text-xl font-bold text-gray-900">课程学习资料</h3>
245
- <button
246
- onClick={() => setSelectedLink(null)}
247
- className="text-gray-400 hover:text-gray-600 transition-colors"
248
- >
249
- <X className="w-6 h-6" />
250
- </button>
251
- </div>
252
 
253
- <div className="bg-blue-50 border border-blue-100 rounded-xl p-4 mb-6">
254
- <p className="text-sm text-blue-800 mb-2">请复制以下链接在浏览器中打开或直接点击访问:</p>
255
- <div className="flex items-center gap-2">
256
- <input
257
- type="text"
258
- readOnly
259
- value={selectedLink}
260
- className="flex-1 bg-white border border-blue-200 rounded-lg px-3 py-2 text-sm text-gray-700 outline-none"
261
- />
262
- <button
263
- onClick={() => copyToClipboard(selectedLink)}
264
- className="p-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors"
265
- title="复制链接"
 
 
266
  >
267
- <Copy className="w-4 h-4" />
 
 
268
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  </div>
270
  </div>
271
-
272
- <div className="flex gap-3 justify-end">
273
- <button
274
- onClick={() => setSelectedLink(null)}
275
- className="px-5 py-2.5 border border-gray-200 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition-colors"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  >
277
- 关闭
 
278
  </button>
279
- <a
280
- href={selectedLink}
281
- target="_blank"
282
- rel="noopener noreferrer"
283
- className="px-5 py-2.5 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors"
284
- onClick={() => setSelectedLink(null)}
285
  >
286
- 直接访问
287
- </a>
 
288
  </div>
 
 
 
 
 
 
 
 
 
289
  </div>
290
  </div>
291
  )}
 
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, Coins } from "lucide-react";
7
  import Link from "next/link";
8
  import { useAuthStore } from "@/lib/store";
9
+ import Comments from "./comments";
10
 
11
  export default function CourseDetail() {
12
  const { id } = useParams();
 
20
  const [selectedLink, setSelectedLink] = useState<string | null>(null);
21
  const [isLiked, setIsLiked] = useState(false);
22
  const [isStarred, setIsStarred] = useState(false);
23
+ const [showDonateModal, setShowDonateModal] = useState(false);
24
+ const [donateAmount, setDonateAmount] = useState<number | ''>(2);
25
+ const [donateMessage, setDonateMessage] = useState('');
26
+ const [paymentMethod, setPaymentMethod] = useState<'alipay' | 'wechat' | null>(null);
27
  const hasViewedRef = useRef(false);
28
 
29
+ const presetAmounts = [2, 5, 10, 20, 50];
30
+
31
  useEffect(() => {
32
  const fetchData = async () => {
33
  try {
 
142
  }
143
  };
144
 
145
+ const handleDonate = async () => {
146
+ if (!token) {
147
+ router.push('/login');
148
+ return;
149
+ }
150
+ if (!donateAmount || donateAmount <= 0) {
151
+ alert('请输入有效的打赏金额');
152
+ return;
153
+ }
154
+ if (!paymentMethod) {
155
+ alert('请选择支付方式');
156
+ return;
157
+ }
158
+
159
+ try {
160
+ const res = await api.post('/api/orders/create', {
161
+ courseId: Number(id),
162
+ price: Number(donateAmount),
163
+ isDonation: true,
164
+ message: donateMessage
165
+ });
166
+
167
+ if (res.success) {
168
+ setShowDonateModal(false);
169
+ router.push(`/payment/${res.data.orderId}`);
170
+ }
171
+ } catch (err) {
172
+ console.error(err);
173
+ alert('创建打赏订单失败');
174
+ }
175
+ };
176
+
177
  if (loading) {
178
  return <div className="animate-pulse h-96 bg-white rounded-xl"></div>;
179
  }
 
227
 
228
  <div className="flex justify-between items-center pt-6 border-t">
229
  {/* Interaction Buttons */}
230
+ <div className="flex gap-6 items-center">
231
  <button
232
  onClick={handleToggleLike}
233
  className={`flex items-center gap-2 transition-colors ${isLiked ? 'text-blue-600' : 'text-gray-500 hover:text-gray-900'}`}
 
242
  <Star className={`w-6 h-6 ${isStarred ? 'fill-current' : ''}`} />
243
  <span className="font-medium">{course.starCount || 0}</span>
244
  </button>
245
+
246
+ <button
247
+ onClick={() => setShowDonateModal(true)}
248
+ className="flex items-center gap-1.5 px-4 py-1.5 bg-pink-500 hover:bg-pink-600 text-white rounded-full font-medium transition-colors ml-4"
249
+ >
250
+ <Coins className="w-4 h-4" />
251
+ 给TA打赏
252
+ </button>
253
  </div>
254
 
255
  {/* Action Buttons */}
 
263
  已购买
264
  </button>
265
  <button
266
+ disabled
267
+ className="bg-green-50 text-green-600 px-8 py-2.5 rounded-xl font-semibold text-base flex items-center cursor-default"
268
  >
269
+ 资料已发送至您的邮箱
 
270
  </button>
271
  </>
272
  ) : (
 
282
  </div>
283
  </div>
284
 
285
+ <Comments courseId={Number(id)} />
286
+
287
+ {/* 打赏弹窗 */}
288
+ {showDonateModal && (
289
+ <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
290
+ <div className="bg-white rounded-2xl max-w-md w-full p-6 shadow-xl relative">
291
+ <button
292
+ onClick={() => setShowDonateModal(false)}
293
+ className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
294
+ >
295
+ <X className="w-5 h-5" />
296
+ </button>
 
297
 
298
+ <div className="text-center mb-6">
299
+ <h3 className="text-lg font-medium text-gray-800">按需赞赏支持创作者</h3>
300
+ </div>
301
+
302
+ {/* 金额选择网格 */}
303
+ <div className="grid grid-cols-3 gap-3 mb-6">
304
+ {presetAmounts.map(amount => (
305
+ <button
306
+ key={amount}
307
+ onClick={() => setDonateAmount(amount)}
308
+ className={`flex items-center justify-center gap-1 py-3 rounded-lg border-2 transition-all ${
309
+ donateAmount === amount
310
+ ? 'border-red-500 text-red-500 bg-red-50'
311
+ : 'border-gray-100 text-gray-700 hover:border-red-200'
312
+ }`}
313
  >
314
+ <Coins className="w-4 h-4" />
315
+ <span className="font-bold text-lg">{amount}</span>
316
+ <span className="text-sm">¥</span>
317
  </button>
318
+ ))}
319
+ <div
320
+ className={`flex items-center justify-center gap-1 px-2 rounded-lg border-2 transition-all ${
321
+ !presetAmounts.includes(donateAmount as number) && donateAmount !== ''
322
+ ? 'border-red-500 bg-red-50'
323
+ : 'border-gray-100'
324
+ }`}
325
+ >
326
+ <input
327
+ type="number"
328
+ placeholder="自定义"
329
+ value={!presetAmounts.includes(donateAmount as number) ? donateAmount : ''}
330
+ onChange={(e) => {
331
+ const val = e.target.value;
332
+ setDonateAmount(val === '' ? '' : Number(val));
333
+ }}
334
+ className="w-full bg-transparent border-none text-center font-bold text-gray-700 outline-none placeholder:font-normal"
335
+ min="0.1"
336
+ step="0.1"
337
+ />
338
  </div>
339
  </div>
340
+
341
+ {/* 留言输入框 */}
342
+ <div className="mb-6">
343
+ <textarea
344
+ value={donateMessage}
345
+ onChange={(e) => setDonateMessage(e.target.value)}
346
+ placeholder="给Ta留言..."
347
+ className="w-full bg-gray-50 border border-gray-200 rounded-xl p-3 text-sm text-gray-700 outline-none focus:border-red-300 focus:ring-2 focus:ring-red-100 resize-none h-20"
348
+ />
349
+ </div>
350
+
351
+ {/* 显示当前金额 */}
352
+ <div className="text-center mb-6">
353
+ <span className="text-2xl text-green-600 font-bold">¥ </span>
354
+ <span className="text-4xl text-green-600 font-bold">{donateAmount || '0'}</span>
355
+ </div>
356
+
357
+ {/* 支付方式选择 */}
358
+ <div className="flex gap-3 mb-6">
359
+ <button
360
+ onClick={() => setPaymentMethod('alipay')}
361
+ className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl border-2 transition-all ${
362
+ paymentMethod === 'alipay' ? 'border-blue-500 bg-blue-50 text-blue-600' : 'border-gray-100 text-gray-600 hover:bg-gray-50'
363
+ }`}
364
  >
365
+ <div className="w-5 h-5 bg-blue-500 rounded-full text-white flex items-center justify-center text-xs font-bold">支</div>
366
+ <span className="font-medium">支付宝</span>
367
  </button>
368
+ <button
369
+ onClick={() => setPaymentMethod('wechat')}
370
+ className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl border-2 transition-all ${
371
+ paymentMethod === 'wechat' ? 'border-green-500 bg-green-50 text-green-600' : 'border-gray-100 text-gray-600 hover:bg-gray-50'
372
+ }`}
 
373
  >
374
+ <div className="w-5 h-5 bg-green-500 rounded-full text-white flex items-center justify-center text-xs font-bold">微</div>
375
+ <span className="font-medium">微信支付</span>
376
+ </button>
377
  </div>
378
+
379
+ {/* 确认支付按钮 */}
380
+ <button
381
+ onClick={handleDonate}
382
+ disabled={!paymentMethod || !donateAmount}
383
+ className="w-full py-3.5 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white rounded-xl font-bold text-lg transition-colors shadow-lg shadow-blue-200"
384
+ >
385
+ {paymentMethod ? '确认打赏' : '请选择支付方式'}
386
+ </button>
387
  </div>
388
  </div>
389
  )}
frontend/src/app/login/page.tsx CHANGED
@@ -11,6 +11,7 @@ export default function Login() {
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
 
@@ -23,16 +24,16 @@ export default function Login() {
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('/');
@@ -80,22 +81,35 @@ export default function Login() {
80
  </div>
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
  />
94
- <button type="button" className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors whitespace-nowrap">
95
- 发送验证码
96
- </button>
97
  </div>
98
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  )}
100
 
101
  <button
 
11
  const [isLogin, setIsLogin] = useState(true);
12
  const [email, setEmail] = useState("");
13
  const [password, setPassword] = useState("");
14
+ const [nickname, setNickname] = useState("");
15
  const [emailCode, setEmailCode] = useState("");
16
  const [loading, setLoading] = useState(false);
17
 
 
24
  const res = await api.post('/api/auth/login', { email, password });
25
  if (res.success) {
26
  setAuth(
27
+ { id: res.data.userId, email, nickname: res.data.nickname, role: res.data.role, isVip: res.data.isVip },
28
  res.data.token
29
  );
30
  router.push('/');
31
  }
32
  } else {
33
+ const res = await api.post('/api/auth/register', { email, password, nickname, emailCode: emailCode || '123456' });
34
  if (res.success) {
35
  setAuth(
36
+ { id: res.data.userId, email, nickname: res.data.nickname, role: res.data.role, isVip: res.data.isVip },
37
  res.data.token
38
  );
39
  router.push('/');
 
81
  </div>
82
 
83
  {!isLogin && (
84
+ <>
85
+ <div>
86
+ <label className="block text-sm font-medium text-gray-700 mb-1">用户名</label>
87
  <input
88
  type="text"
89
+ required={!isLogin}
90
+ value={nickname}
91
+ onChange={(e) => setNickname(e.target.value)}
92
+ 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"
93
+ placeholder="请输入用户名"
94
  />
 
 
 
95
  </div>
96
+ <div>
97
+ <label className="block text-sm font-medium text-gray-700 mb-1">邮箱验证码 (模拟: 123456)</label>
98
+ <div className="flex gap-3">
99
+ <input
100
+ type="text"
101
+ required
102
+ value={emailCode}
103
+ onChange={(e) => setEmailCode(e.target.value)}
104
+ 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"
105
+ placeholder="6位验证码"
106
+ />
107
+ <button type="button" className="px-4 py-3 bg-gray-100 text-gray-700 rounded-xl text-sm font-medium hover:bg-gray-200 transition-colors whitespace-nowrap">
108
+ 发送验证码
109
+ </button>
110
+ </div>
111
+ </div>
112
+ </>
113
  )}
114
 
115
  <button
frontend/src/app/page.tsx CHANGED
@@ -1,20 +1,14 @@
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);
@@ -22,6 +16,16 @@ function HomeContent() {
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());
@@ -36,20 +40,27 @@ function HomeContent() {
36
  });
37
 
38
  useEffect(() => {
 
39
  const fetchCourses = async () => {
40
  try {
41
  const res = await api.get('/api/courses');
42
- if (res.success) {
43
  setCourses(res.data);
44
  }
45
  } catch (err) {
46
  console.error(err);
47
  } finally {
48
- setLoading(false);
 
 
49
  }
50
  };
51
 
52
  fetchCourses();
 
 
 
 
53
  }, []);
54
 
55
  const displayTitle = currentCategory && CATEGORY_MAP[currentCategory]
@@ -59,18 +70,68 @@ function HomeContent() {
59
  return (
60
  <div className="space-y-8">
61
  {/* Search Section */}
62
- <section className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
63
- <div className="relative">
64
- <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
65
- <input
66
- type="text"
67
- placeholder="搜索课程..."
68
- value={searchQuery}
69
- onChange={(e) => setSearchQuery(e.target.value)}
70
- 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"
71
- />
72
- </div>
73
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  {/* Course List */}
76
  {loading ? (
@@ -113,7 +174,7 @@ function HomeContent() {
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
  );
 
1
  "use client";
2
 
3
+ import { useEffect, useState, Suspense, useMemo } 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
+ import { useConfigStore } from "@/lib/store";
 
 
 
 
 
 
 
9
 
10
  function HomeContent() {
11
+ const { uiConfig } = useConfigStore();
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
  const [courses, setCourses] = useState<any[]>([]);
14
  const [loading, setLoading] = useState(true);
 
16
  const searchParams = useSearchParams();
17
  const currentCategory = searchParams.get('category');
18
 
19
+ const CATEGORY_MAP = useMemo(() => {
20
+ const map: Record<string, string> = {};
21
+ if (uiConfig?.navLinks) {
22
+ uiConfig.navLinks.forEach(link => {
23
+ map[link.value] = link.label;
24
+ });
25
+ }
26
+ return map;
27
+ }, [uiConfig]);
28
+
29
  const filteredCourses = courses.filter(course => {
30
  const matchesSearch = course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
31
  course.description?.toLowerCase().includes(searchQuery.toLowerCase());
 
40
  });
41
 
42
  useEffect(() => {
43
+ let isMounted = true;
44
  const fetchCourses = async () => {
45
  try {
46
  const res = await api.get('/api/courses');
47
+ if (isMounted && res.success) {
48
  setCourses(res.data);
49
  }
50
  } catch (err) {
51
  console.error(err);
52
  } finally {
53
+ if (isMounted) {
54
+ setLoading(false);
55
+ }
56
  }
57
  };
58
 
59
  fetchCourses();
60
+
61
+ return () => {
62
+ isMounted = false;
63
+ };
64
  }, []);
65
 
66
  const displayTitle = currentCategory && CATEGORY_MAP[currentCategory]
 
70
  return (
71
  <div className="space-y-8">
72
  {/* Search Section */}
73
+ {uiConfig?.heroBackground ? (
74
+ <section
75
+ className="relative rounded-2xl overflow-hidden shadow-sm border border-gray-100 min-h-[300px] flex items-center justify-center p-8"
76
+ style={{
77
+ backgroundImage: `url(${uiConfig.heroBackground})`,
78
+ backgroundSize: 'cover',
79
+ backgroundPosition: 'center',
80
+ }}
81
+ >
82
+ <div className="absolute inset-0 bg-black/30 backdrop-blur-[2px]"></div>
83
+ <div className="relative w-full max-w-2xl mx-auto z-10">
84
+ <h1 className="text-3xl md:text-4xl font-bold text-white text-center mb-8 drop-shadow-md">
85
+ 探索精选课程,提升你的技能
86
+ </h1>
87
+ <div className="relative">
88
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
89
+ <input
90
+ type="text"
91
+ placeholder="搜索课程..."
92
+ value={searchQuery}
93
+ onChange={(e) => setSearchQuery(e.target.value)}
94
+ className="w-full pl-12 pr-4 py-4 rounded-xl border-none focus:ring-4 focus:ring-blue-500/30 outline-none transition-all text-gray-900 placeholder:text-gray-400 text-lg shadow-lg bg-white/95 backdrop-blur-sm"
95
+ />
96
+ </div>
97
+ </div>
98
+ </section>
99
+ ) : (
100
+ <section className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100">
101
+ <div className="relative">
102
+ <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
103
+ <input
104
+ type="text"
105
+ placeholder="搜索课程..."
106
+ value={searchQuery}
107
+ onChange={(e) => setSearchQuery(e.target.value)}
108
+ 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"
109
+ />
110
+ </div>
111
+ </section>
112
+ )}
113
+
114
+ {/* Banners Section */}
115
+ {uiConfig?.banners && uiConfig.banners.length > 0 && (
116
+ <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
117
+ {uiConfig.banners.map((banner, idx) => (
118
+ <a
119
+ key={idx}
120
+ href={banner.linkUrl || '#'}
121
+ target={banner.linkUrl ? "_blank" : "_self"}
122
+ rel="noopener noreferrer"
123
+ className="block rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow group relative aspect-[21/3] md:aspect-[16/3]"
124
+ >
125
+ {/* eslint-disable-next-line @next/next/no-img-element */}
126
+ <img
127
+ src={banner.imageUrl}
128
+ alt={`Banner ${idx + 1}`}
129
+ className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
130
+ />
131
+ </a>
132
+ ))}
133
+ </section>
134
+ )}
135
 
136
  {/* Course List */}
137
  {loading ? (
 
174
 
175
  export default function Home() {
176
  return (
177
+ <Suspense fallback={<div className="p-8 text-center text-gray-500">页面内容加载中...</div>}>
178
  <HomeContent />
179
  </Suspense>
180
  );
frontend/src/app/payment/[id]/page.tsx CHANGED
@@ -1,15 +1,17 @@
1
  "use client";
2
 
3
- import { useState } from "react";
4
  import { useParams, useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
  import { CreditCard, Smartphone } from "lucide-react";
 
7
 
8
  export default function Payment() {
9
  const { id } = useParams();
10
  const router = useRouter();
11
  const [payType, setPayType] = useState<'wechat' | 'alipay'>('wechat');
12
  const [loading, setLoading] = useState(false);
 
13
 
14
  const handlePay = async () => {
15
  setLoading(true);
@@ -29,7 +31,18 @@ export default function Payment() {
29
  status: 'SUCCESS'
30
  });
31
 
32
- router.push('/user/courses');
 
 
 
 
 
 
 
 
 
 
 
33
  }
34
  } catch (err) {
35
  console.error(err);
 
1
  "use client";
2
 
3
+ import { useState, useEffect } from "react";
4
  import { useParams, useRouter } from "next/navigation";
5
  import { api } from "@/lib/api";
6
  import { CreditCard, Smartphone } from "lucide-react";
7
+ import { useAuthStore } from "@/lib/store";
8
 
9
  export default function Payment() {
10
  const { id } = useParams();
11
  const router = useRouter();
12
  const [payType, setPayType] = useState<'wechat' | 'alipay'>('wechat');
13
  const [loading, setLoading] = useState(false);
14
+ const { setAuth } = useAuthStore();
15
 
16
  const handlePay = async () => {
17
  setLoading(true);
 
31
  status: 'SUCCESS'
32
  });
33
 
34
+ // Refresh user profile to update VIP status if it was a VIP order
35
+ const token = localStorage.getItem('token');
36
+ if (token) {
37
+ const profileRes = await api.get('/api/auth/profile');
38
+ if (profileRes.success) {
39
+ setAuth(profileRes.data, token);
40
+ }
41
+ }
42
+
43
+ // Redirect
44
+ alert('支付流程完成');
45
+ router.back();
46
  }
47
  } catch (err) {
48
  console.error(err);
frontend/src/app/user/stars/page.tsx CHANGED
@@ -8,10 +8,11 @@ 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(() => {
 
8
  import { useRouter } from "next/navigation";
9
 
10
  export default function UserStarsPage() {
11
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
12
  const [courses, setCourses] = useState<any[]>([]);
13
  const [loading, setLoading] = useState(true);
14
  const [searchQuery, setSearchQuery] = useState("");
15
+ const { token } = useAuthStore();
16
  const router = useRouter();
17
 
18
  useEffect(() => {
frontend/src/lib/store.ts CHANGED
@@ -5,6 +5,7 @@ interface User {
5
  email: string;
6
  nickname: string;
7
  role: string;
 
8
  }
9
 
10
  interface AuthState {
@@ -26,3 +27,33 @@ export const useAuthStore = create<AuthState>((set) => ({
26
  set({ user: null, token: null });
27
  },
28
  }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  email: string;
6
  nickname: string;
7
  role: string;
8
+ isVip?: boolean;
9
  }
10
 
11
  interface AuthState {
 
27
  set({ user: null, token: null });
28
  },
29
  }));
30
+
31
+ export interface NavLink {
32
+ label: string;
33
+ value: string;
34
+ }
35
+
36
+ export interface Banner {
37
+ imageUrl: string;
38
+ linkUrl: string;
39
+ }
40
+
41
+ export interface UiConfig {
42
+ siteName: string;
43
+ logo: string;
44
+ footerText: string;
45
+ heroBackground?: string;
46
+ navLinks: NavLink[];
47
+ banners?: Banner[];
48
+ memberFee?: number;
49
+ }
50
+
51
+ interface ConfigState {
52
+ uiConfig: UiConfig | null;
53
+ setUiConfig: (config: UiConfig) => void;
54
+ }
55
+
56
+ export const useConfigStore = create<ConfigState>((set) => ({
57
+ uiConfig: null,
58
+ setUiConfig: (uiConfig) => set({ uiConfig }),
59
+ }));
管理员账号 CHANGED
@@ -1 +1 @@
1
- admin@example.com (密码: 123456 )
 
1
+ 管理员账号:admin@example.com (密码: 123456 )