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