Spaces:
Sleeping
Sleeping
trae-bot commited on
Commit ·
426f2a4
1
Parent(s): f45e448
Update project
Browse files- backend/src/admin/admin.controller.ts +20 -0
- backend/src/admin/admin.service.ts +62 -1
- backend/src/app.module.ts +14 -1
- backend/src/auth/auth.service.ts +4 -10
- backend/src/auth/dto/register.dto.ts +5 -1
- backend/src/config/config.controller.ts +25 -0
- backend/src/config/config.module.ts +13 -0
- backend/src/config/config.service.ts +67 -0
- backend/src/courses/courses.controller.ts +24 -0
- backend/src/courses/courses.module.ts +2 -1
- backend/src/courses/courses.service.ts +94 -2
- backend/src/entities/comment.entity.ts +58 -0
- backend/src/entities/course.entity.ts +4 -0
- backend/src/entities/order.entity.ts +13 -1
- backend/src/entities/system-config.entity.ts +28 -0
- backend/src/entities/user.entity.ts +7 -0
- backend/src/orders/dto/create-order.dto.ts +17 -5
- backend/src/orders/orders.module.ts +2 -1
- backend/src/orders/orders.service.ts +32 -11
- backend/src/payment/payment.module.ts +3 -1
- backend/src/payment/payment.service.ts +50 -19
- backend/src/seed-categories.ts +10 -7
- deploy/nginx/zhyjs.com.cn.conf +19 -0
- frontend/package-lock.json +104 -0
- frontend/package.json +1 -0
- frontend/src/app/admin/page.tsx +567 -19
- frontend/src/app/client-layout.tsx +159 -15
- frontend/src/app/course/[id]/comments.tsx +250 -0
- frontend/src/app/course/[id]/page.tsx +145 -47
- frontend/src/app/login/page.tsx +29 -15
- frontend/src/app/page.tsx +85 -24
- frontend/src/app/payment/[id]/page.tsx +15 -2
- frontend/src/app/user/stars/page.tsx +2 -1
- frontend/src/lib/store.ts +31 -0
- 管理员账号 +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: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
@
|
| 6 |
-
courseId: number;
|
| 7 |
|
| 8 |
@IsNumber()
|
| 9 |
-
@
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 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
|
| 45 |
-
amount
|
| 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 |
-
//
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
| 24 |
course.category = randomCategory;
|
| 25 |
await courseRepository.save(course);
|
| 26 |
-
console.log(
|
|
|
|
|
|
|
| 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,
|
| 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
|
|
|
|
| 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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 |
-
|
| 288 |
-
|
| 289 |
-
|
| 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 |
+
×
|
| 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">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
<nav className="flex items-center gap-6">
|
| 22 |
<Link href="/" className="text-gray-600 hover:text-blue-600 font-medium">首页</Link>
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 41 |
-
<
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
>
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 220 |
-
className="bg-
|
| 221 |
>
|
| 222 |
-
|
| 223 |
-
获取资料
|
| 224 |
</button>
|
| 225 |
</>
|
| 226 |
) : (
|
|
@@ -236,56 +282,108 @@ export default function CourseDetail() {
|
|
| 236 |
</div>
|
| 237 |
</div>
|
| 238 |
|
| 239 |
-
{
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
</div>
|
| 252 |
|
| 253 |
-
<div className="
|
| 254 |
-
<
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
>
|
| 267 |
-
<
|
|
|
|
|
|
|
| 268 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
</div>
|
| 270 |
</div>
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
>
|
| 277 |
-
|
|
|
|
| 278 |
</button>
|
| 279 |
-
<
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
onClick={() => setSelectedLink(null)}
|
| 285 |
>
|
| 286 |
-
|
| 287 |
-
|
|
|
|
| 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 |
-
<
|
| 84 |
-
<
|
| 85 |
-
|
| 86 |
<input
|
| 87 |
type="text"
|
| 88 |
-
required
|
| 89 |
-
value={
|
| 90 |
-
onChange={(e) =>
|
| 91 |
-
className="
|
| 92 |
-
placeholder="
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
/>
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 )
|