add
This commit is contained in:
parent
eca128de5f
commit
8596b467ff
|
@ -21,5 +21,20 @@ module.exports = {
|
|||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
// 允许使用 any 类型
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
// 允许声明但未使用的变量
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all', // 检查所有变量
|
||||
args: 'none', // 不检查函数参数
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 禁止使用未声明的变量
|
||||
'no-undef': 'error',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -26,7 +26,7 @@ export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
|
|||
async checkFileExists(hash: string): Promise<Resource | null> {
|
||||
return this.findFirst({
|
||||
where: {
|
||||
hash,
|
||||
// hash,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { db, InitAppConfigs, InitRoles, InitTaxonomies, ObjectType } from "@nice/common";
|
||||
import {
|
||||
db,
|
||||
InitAppConfigs,
|
||||
InitRoles,
|
||||
InitTaxonomies,
|
||||
ObjectType,
|
||||
} from '@nice/common';
|
||||
import { AuthService } from '@server/auth/auth.service';
|
||||
import { MinioService } from '@server/utils/minio/minio.service';
|
||||
import { AppConfigService } from '@server/models/app-config/app-config.service';
|
||||
|
@ -12,8 +18,8 @@ export class InitService {
|
|||
private readonly appConfigService: AppConfigService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly genDevService: GenDevService
|
||||
) { }
|
||||
private readonly genDevService: GenDevService,
|
||||
) {}
|
||||
private async createRoles() {
|
||||
this.logger.log('Checking existing system roles');
|
||||
for (const role of InitRoles) {
|
||||
|
@ -34,7 +40,9 @@ export class InitService {
|
|||
this.logger.log('Checking existing taxonomies');
|
||||
|
||||
const existingTaxonomies = await db.taxonomy.findMany();
|
||||
const existingTaxonomyMap = new Map(existingTaxonomies.map(taxonomy => [taxonomy.name, taxonomy]));
|
||||
const existingTaxonomyMap = new Map(
|
||||
existingTaxonomies.map((taxonomy) => [taxonomy.name, taxonomy]),
|
||||
);
|
||||
|
||||
for (const [index, taxonomy] of InitTaxonomies.entries()) {
|
||||
const existingTaxonomy = existingTaxonomyMap.get(taxonomy.name);
|
||||
|
@ -50,7 +58,9 @@ export class InitService {
|
|||
this.logger.log(`Created new taxonomy: ${taxonomy.name}`);
|
||||
} else {
|
||||
// Check for differences and update if necessary
|
||||
const differences = Object.keys(taxonomy).filter(key => taxonomy[key] !== existingTaxonomy[key]);
|
||||
const differences = Object.keys(taxonomy).filter(
|
||||
(key) => taxonomy[key] !== existingTaxonomy[key],
|
||||
);
|
||||
|
||||
if (differences.length > 0) {
|
||||
await db.taxonomy.update({
|
||||
|
@ -110,7 +120,7 @@ export class InitService {
|
|||
}
|
||||
}
|
||||
private async createBucket() {
|
||||
await this.minioService.createBucket('app')
|
||||
await this.minioService.createBucket('app');
|
||||
}
|
||||
private async initAppConfigs() {
|
||||
const existingConfigs = await db.appConfig.findMany();
|
||||
|
@ -130,9 +140,9 @@ export class InitService {
|
|||
this.logger.log('Initializing root account');
|
||||
await this.createRoot();
|
||||
this.logger.log('Initializing taxonomies');
|
||||
await this.createOrUpdateTaxonomy()
|
||||
this.logger.log('Initialize minio')
|
||||
await this.createBucket()
|
||||
await this.createOrUpdateTaxonomy();
|
||||
this.logger.log('Initialize minio');
|
||||
await this.createBucket();
|
||||
this.logger.log('Initializing appConfigs');
|
||||
await this.initAppConfigs();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
|
@ -142,7 +152,5 @@ export class InitService {
|
|||
this.logger.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,28 +1,43 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
// 允许使用 any 类型
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
// 允许声明但未使用的变量
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
vars: "all", // 检查所有变量
|
||||
args: "none", // 不检查函数参数
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 禁止使用未声明的变量
|
||||
"no-undef": "error",
|
||||
},
|
||||
)
|
||||
}
|
||||
);
|
||||
|
|
|
@ -46,8 +46,10 @@
|
|||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.12",
|
||||
"framer-motion": "^11.15.0",
|
||||
"hls.js": "^1.5.18",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"mitt": "^3.0.1",
|
||||
"plyr-react": "^5.3.0",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
|
@ -57,9 +59,9 @@
|
|||
"react-router-dom": "^6.24.1",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"uuid": "^10.0.0",
|
||||
"yjs": "^13.6.20",
|
||||
"zod": "^3.23.8",
|
||||
"uuid": "^10.0.0"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import CourseDetail from "@web/src/components/models/course/detail/CourseDetail";
|
||||
import CourseEditor from "@web/src/components/models/course/manage/CourseEditor";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
export function CourseDetailPage() {
|
||||
const { id } = useParams();
|
||||
console.log("Course ID:", id);
|
||||
return <CourseDetail id={id}></CourseDetail>;
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import { CourseCard } from "@web/src/components/models/course/card/CourseCard"
|
||||
import { CourseDetail } from "@web/src/components/models/course/detail/course-detail"
|
||||
import { CourseSyllabus } from "@web/src/components/models/course/detail/course-syllabus"
|
||||
import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
|
||||
import { CourseDetail } from "@web/src/components/models/course/detail/CourseDetailContent";
|
||||
import { CourseSyllabus } from "@web/src/components/models/course/detail/CourseSyllabus";
|
||||
|
||||
export const CoursePage = () => {
|
||||
// 假设这些数据从API获取
|
||||
const course: any = {
|
||||
/* course data */
|
||||
}
|
||||
};
|
||||
const sections: any = [
|
||||
/* sections data */
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
|
@ -24,11 +24,11 @@ export const CoursePage = () => {
|
|||
<CourseSyllabus
|
||||
sections={sections}
|
||||
onLectureClick={(lectureId) => {
|
||||
console.log('Clicked lecture:', lectureId)
|
||||
console.log("Clicked lecture:", lectureId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,37 +8,39 @@ import { useNavigate } from "react-router-dom";
|
|||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
export default function InstructorCoursesPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { user } = useAuth()
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: paginationRes, refetch } = api.course.findManyWithPagination.useQuery({
|
||||
const { data: paginationRes, refetch } =
|
||||
api.course.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize: 8,
|
||||
where: {
|
||||
instructors: {
|
||||
some: {
|
||||
instructorId: user?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
instructorId: user?.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
refetch()
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-slate-50 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-3xl font-semibold text-slate-800">我教授的课程</h1>
|
||||
<h1 className="text-3xl font-semibold text-slate-800">
|
||||
我教授的课程
|
||||
</h1>
|
||||
<Button
|
||||
onClick={() => navigate("/course/manage")}
|
||||
variant="primary"
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}
|
||||
>
|
||||
leftIcon={<PlusIcon className="w-5 h-5" />}>
|
||||
创建课程
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -47,7 +49,16 @@ export default function InstructorCoursesPage() {
|
|||
onPageChange={handlePageChange}
|
||||
currentPage={currentPage}
|
||||
courses={paginationRes?.items as any}
|
||||
renderItem={(course) => <CourseCard course={course} />}
|
||||
renderItem={(course) => (
|
||||
<CourseCard
|
||||
onClick={() => {
|
||||
navigate(`/course/${course.id}/detail`, {
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
course={course}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { CourseDto } from '@nice/common';
|
||||
import { Card } from '@web/src/components/presentation/container/Card';
|
||||
import { CourseHeader } from './CourseHeader';
|
||||
import { CourseStats } from './CourseStats';
|
||||
import { CourseDto } from "@nice/common";
|
||||
import { Card } from "@web/src/components/presentation/container/Card";
|
||||
import { CourseHeader } from "./CourseHeader";
|
||||
import { CourseStats } from "./CourseStats";
|
||||
import { Popover } from "@web/src/components/presentation/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CourseCardProps {
|
||||
course: CourseDto;
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
import { CalendarIcon, UserGroupIcon, AcademicCapIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
CalendarIcon,
|
||||
UserGroupIcon,
|
||||
AcademicCapIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CourseLevelLabel } from "@nice/common";
|
||||
|
||||
interface CourseHeaderProps {
|
||||
title: string;
|
||||
|
@ -21,29 +26,23 @@ export const CourseHeader = ({
|
|||
<div className="relative">
|
||||
{thumbnail && (
|
||||
<div className="relative h-48 w-full">
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={title}
|
||||
className="object-cover"
|
||||
/>
|
||||
<img src={thumbnail} alt={title} className="object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold text-gray-900 ">{title}</h3>
|
||||
{subTitle && (
|
||||
<p className="mt-2 text-gray-600 ">{subTitle}</p>
|
||||
)}
|
||||
{subTitle && <p className="mt-2 text-gray-600 ">{subTitle}</p>}
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 ">
|
||||
{level && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AcademicCapIcon className="h-4 w-4" />
|
||||
<span>{level}</span>
|
||||
<span>{CourseLevelLabel[level]}</span>
|
||||
</div>
|
||||
)}
|
||||
{numberOfStudents !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<UserGroupIcon className="h-4 w-4" />
|
||||
<span>{numberOfStudents} students</span>
|
||||
<span>{numberOfStudents} 人学习中</span>
|
||||
</div>
|
||||
)}
|
||||
{publishedAt && (
|
||||
|
|
|
@ -23,7 +23,7 @@ export const CourseStats = ({
|
|||
{averageRating.toFixed(1)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
{numberOfReviews} reviews
|
||||
{numberOfReviews} 观看量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -36,7 +36,7 @@ export const CourseStats = ({
|
|||
{completionRate}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
Completion
|
||||
完成率
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -49,7 +49,7 @@ export const CourseStats = ({
|
|||
{Math.floor(totalDuration / 60)}h {totalDuration % 60}m
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ">
|
||||
Duration
|
||||
总时长
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { CourseDetailProvider } from "./CourseDetailContext";
|
||||
import CourseDetailLayout from "./CourseDetailLayout";
|
||||
|
||||
export default function CourseDetail({ id }: { id?: string }) {
|
||||
const iframeStyle = {
|
||||
width: "50%",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<CourseDetailProvider editId={id}>
|
||||
<CourseDetailLayout></CourseDetailLayout>
|
||||
</CourseDetailProvider>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Course } from "@nice/common";
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import CourseDetailSkeleton from "./CourseDetailSkeleton";
|
||||
interface CourseDetailProps {
|
||||
course: Course;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const CourseDetailContent: React.FC<CourseDetailProps> = ({
|
||||
course,
|
||||
isLoading,
|
||||
}) => {
|
||||
if (isLoading || !course) {
|
||||
return <CourseDetailSkeleton />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 课程标题区域 */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">{course.title}</h1>
|
||||
{course.subTitle && (
|
||||
<p className="text-xl text-gray-600">{course.subTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 课程描述 */}
|
||||
<div className="prose max-w-none">
|
||||
<p>{course.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 学习目标 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.objectives.map((objective, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span>{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合人群 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.audiences.map((audience, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<span>{audience}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 课程要求 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
{course.requirements.map((requirement, index) => (
|
||||
<li key={index}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 可获得技能 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
import { api, useCourse } from "@nice/client";
|
||||
import { courseDetailSelect, CourseDto } from "@nice/common";
|
||||
import React, { createContext, ReactNode, useState } from "react";
|
||||
import { string } from "zod";
|
||||
|
||||
interface CourseDetailContextType {
|
||||
editId?: string; // 添加 editId
|
||||
course?: CourseDto;
|
||||
selectedLectureId?: string | undefined;
|
||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
interface CourseFormProviderProps {
|
||||
children: ReactNode;
|
||||
editId?: string; // 添加 editId 参数
|
||||
}
|
||||
export const CourseDetailContext =
|
||||
createContext<CourseDetailContextType | null>(null);
|
||||
export function CourseDetailProvider({
|
||||
children,
|
||||
editId,
|
||||
}: CourseFormProviderProps) {
|
||||
const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } =
|
||||
api.course.findFirst.useQuery(
|
||||
{
|
||||
where: { id: editId },
|
||||
include: {
|
||||
sections: { include: { lectures: true } },
|
||||
enrollments: true,
|
||||
},
|
||||
},
|
||||
{ enabled: Boolean(editId) }
|
||||
);
|
||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
return (
|
||||
<CourseDetailContext.Provider
|
||||
value={{
|
||||
editId,
|
||||
course,
|
||||
selectedLectureId,
|
||||
setSelectedLectureId,
|
||||
isLoading,
|
||||
}}>
|
||||
{children}
|
||||
</CourseDetailContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import { motion } from "framer-motion";
|
||||
import { useContext, useState } from "react";
|
||||
import { CourseDetailContext } from "./CourseDetailContext";
|
||||
import { CourseSyllabus } from "./CourseSyllabus";
|
||||
import { CourseDetailContent } from "./CourseDetailContent";
|
||||
import CourseVideoPage from "./CourseVideoPage";
|
||||
|
||||
export default function CourseDetailLayout() {
|
||||
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
|
||||
useContext(CourseDetailContext);
|
||||
|
||||
const handleLectureClick = (lectureId: string) => {
|
||||
setSelectedLectureId(lectureId);
|
||||
};
|
||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 主内容区域 */}
|
||||
<motion.div
|
||||
animate={{
|
||||
width: isSyllabusOpen ? "66.666667%" : "100%",
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="relative">
|
||||
<CourseVideoPage
|
||||
course={course}
|
||||
isLoading={isLoading}
|
||||
videoSrc="https://flipfit-cdn.akamaized.net/flip_hls/664ce52bd6fcda001911a88c-8f1c4d/video_h1.m3u8"
|
||||
videoPoster="https://picsum.photos/800/450"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 课程大纲侧边栏 */}
|
||||
<CourseSyllabus
|
||||
sections={course?.sections || []}
|
||||
onLectureClick={handleLectureClick}
|
||||
isOpen={isSyllabusOpen}
|
||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
SkeletonItem,
|
||||
SkeletonSection,
|
||||
} from "@web/src/components/presentation/Skeleton";
|
||||
|
||||
export const CourseDetailSkeleton = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 标题骨架屏 */}
|
||||
<div className="space-y-4">
|
||||
<SkeletonItem className="h-9 w-3/4" />
|
||||
<SkeletonItem className="h-6 w-1/2" delay={0.2} />
|
||||
</div>
|
||||
|
||||
{/* 描述骨架屏 */}
|
||||
<SkeletonSection items={2} />
|
||||
|
||||
{/* 学习目标骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 适合人群骨架屏 */}
|
||||
<SkeletonSection title items={4} gridCols />
|
||||
|
||||
{/* 技能骨架屏 */}
|
||||
<div>
|
||||
<SkeletonItem className="h-6 w-32 mb-4" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<SkeletonItem
|
||||
key={i}
|
||||
className="h-8 w-20 rounded-full"
|
||||
delay={i * 0.2}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseDetailSkeleton;
|
|
@ -0,0 +1,214 @@
|
|||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
PlayCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import React, { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { SectionDto } from "@nice/common";
|
||||
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[];
|
||||
onLectureClick?: (lectureId: string) => void;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections((prev) =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter((id) => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
);
|
||||
|
||||
// 平滑滚动到选中的章节
|
||||
setTimeout(() => {
|
||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isOpen ? "33.333333%" : "48px",
|
||||
right: 0,
|
||||
}}
|
||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||
{/* 收起时显示的展开按钮 */}
|
||||
{!isOpen && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onToggle}
|
||||
className="h-full w-12 flex items-center justify-center hover:bg-gray-100">
|
||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* 展开的课程大纲 */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
className="h-full flex flex-col">
|
||||
{/* 标题栏 */}
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 课程大纲内容 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{/* 原有的 sections mapping 内容 */}
|
||||
{sections.map((section) => (
|
||||
<motion.div
|
||||
key={section.id}
|
||||
ref={(el) =>
|
||||
(sectionRefs.current[section.id] =
|
||||
el)
|
||||
}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
onClick={() =>
|
||||
toggleSection(section.id)
|
||||
}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-gray-700">
|
||||
第
|
||||
{Math.floor(section.order)}
|
||||
章
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-left font-medium text-gray-900">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section.totalLectures}
|
||||
节课 ·{" "}
|
||||
{Math.floor(
|
||||
section.totalDuration /
|
||||
60
|
||||
)}
|
||||
分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: expandedSections.includes(
|
||||
section.id
|
||||
)
|
||||
? 180
|
||||
: 0,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedSections.includes(
|
||||
section.id
|
||||
) && (
|
||||
<motion.div
|
||||
initial={{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
height: "auto",
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
}}
|
||||
className="border-t">
|
||||
{section.lectures.map(
|
||||
(lecture) => (
|
||||
<motion.button
|
||||
key={lecture.id}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
x: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
||||
onClick={() =>
|
||||
onLectureClick?.(
|
||||
lecture.id
|
||||
)
|
||||
}>
|
||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium text-gray-800">
|
||||
{
|
||||
lecture.title
|
||||
}
|
||||
</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{
|
||||
lecture.description
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>
|
||||
{
|
||||
lecture.duration
|
||||
}
|
||||
分钟
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
// components/CourseVideoPage.tsx
|
||||
import { motion, useScroll, useTransform } from "framer-motion";
|
||||
import React from "react";
|
||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||
import { CourseDetailContent } from "./CourseDetailContent";
|
||||
import { Course } from "@nice/common";
|
||||
|
||||
interface CourseVideoPageProps {
|
||||
course: Course;
|
||||
videoSrc?: string;
|
||||
videoPoster?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
||||
course,
|
||||
videoSrc,
|
||||
videoPoster,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
// 创建滚动动画效果
|
||||
const { scrollY } = useScroll();
|
||||
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
const videoOpacity = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* 固定的视频区域 */}
|
||||
<motion.div
|
||||
style={{
|
||||
// scale: videoScale,
|
||||
opacity: videoOpacity,
|
||||
}}
|
||||
className="sticky top-0 w-full bg-black z-10">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 课程内容区域 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="max-w-6xl mx-auto px-4 py-8">
|
||||
<CourseDetailContent course={course} isLoading={isLoading} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default CourseVideoPage;
|
|
@ -1,75 +0,0 @@
|
|||
import { CheckCircleIcon } from '@heroicons/react/24/outline'
|
||||
import { Course } from "@nice/common"
|
||||
interface CourseDetailProps {
|
||||
course: Course
|
||||
}
|
||||
|
||||
export const CourseDetail: React.FC<CourseDetailProps> = ({ course }) => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 课程标题区域 */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold">{course.title}</h1>
|
||||
{course.subTitle && (
|
||||
<p className="text-xl text-gray-600">{course.subTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 课程描述 */}
|
||||
<div className="prose max-w-none">
|
||||
<p>{course.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 学习目标 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.objectives.map((objective, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||
<span>{objective}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 适合人群 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{course.audiences.map((audience, index) => (
|
||||
<div key={index} className="flex items-start gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||
<span>{audience}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 课程要求 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||
{course.requirements.map((requirement, index) => (
|
||||
<li key={index}>{requirement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 可获得技能 */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{course.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
|
||||
import { ChevronDownIcon, ClockIcon, PlayCircleIcon } from '@heroicons/react/24/outline'
|
||||
import { useState } from 'react'
|
||||
import { Section, SectionDto } from "@nice/common"
|
||||
interface CourseSyllabusProps {
|
||||
sections: SectionDto[]
|
||||
onLectureClick?: (lectureId: string) => void
|
||||
}
|
||||
|
||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||
sections,
|
||||
onLectureClick
|
||||
}) => {
|
||||
const [expandedSections, setExpandedSections] = useState<string[]>([])
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
setExpandedSections(prev =>
|
||||
prev.includes(sectionId)
|
||||
? prev.filter(id => id !== sectionId)
|
||||
: [...prev, sectionId]
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="border rounded-lg">
|
||||
{/* 章节标题 */}
|
||||
<button
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50"
|
||||
onClick={() => toggleSection(section.id)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium">
|
||||
第{Math.floor(section.order)}章
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-left font-medium">{section.title}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{section.totalLectures}节课 · {Math.floor(section.totalDuration / 60)}分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDownIcon
|
||||
className={`w-5 h-5 transition-transform duration-200 ${expandedSections.includes(section.id) ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 课时列表 */}
|
||||
{expandedSections.includes(section.id) && (
|
||||
<div className="border-t">
|
||||
{section.lectures.map((lecture) => (
|
||||
<button
|
||||
key={lecture.id}
|
||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left"
|
||||
onClick={() => onLectureClick?.(lecture.id)}
|
||||
>
|
||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||
<div className="flex-grow">
|
||||
<h4 className="font-medium">{lecture.title}</h4>
|
||||
{lecture.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{lecture.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span>{lecture.duration}分钟</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// components/presentation/Skeleton.tsx
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
|
||||
export const SkeletonItem = ({
|
||||
className,
|
||||
delay = 0,
|
||||
}: {
|
||||
className: string;
|
||||
delay?: number;
|
||||
}) => (
|
||||
<motion.div
|
||||
className={`bg-gray-200 rounded-md ${className}`}
|
||||
animate={{ opacity: [0.4, 0.7, 0.4] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
delay,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const SkeletonSection = ({
|
||||
title,
|
||||
items,
|
||||
gridCols = false,
|
||||
}: {
|
||||
title?: boolean;
|
||||
items: number;
|
||||
gridCols?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
{title && <SkeletonItem className="h-6 w-32 mb-4" />}
|
||||
<div
|
||||
className={
|
||||
gridCols ? "grid grid-cols-1 md:grid-cols-2 gap-4" : "space-y-2"
|
||||
}>
|
||||
{Array.from({ length: items }).map((_, i) => (
|
||||
<SkeletonItem key={i} className="h-4" delay={i * 0.2} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -0,0 +1,177 @@
|
|||
// components/Popover.tsx
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, {
|
||||
ReactNode,
|
||||
useState,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from "react";
|
||||
|
||||
type PopoverPosition = "top" | "bottom" | "left" | "right";
|
||||
type TriggerType = "hover" | "click" | "focus";
|
||||
|
||||
interface PopoverProps {
|
||||
title?: string;
|
||||
content: ReactNode;
|
||||
position?: PopoverPosition;
|
||||
trigger?: TriggerType;
|
||||
children: ReactNode;
|
||||
// 可选的延迟时间(毫秒),用于 hover 模式
|
||||
hoverDelay?: number;
|
||||
}
|
||||
|
||||
const positionStyles = {
|
||||
top: {
|
||||
initial: { opacity: 0, y: 10, scale: 0.95 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
className: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
||||
},
|
||||
bottom: {
|
||||
initial: { opacity: 0, y: -10, scale: 0.95 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
className: "top-full left-1/2 -translate-x-1/2 mt-2",
|
||||
},
|
||||
left: {
|
||||
initial: { opacity: 0, x: 10, scale: 0.95 },
|
||||
animate: { opacity: 1, x: 0, scale: 1 },
|
||||
className: "right-full top-1/2 -translate-y-1/2 mr-2",
|
||||
},
|
||||
right: {
|
||||
initial: { opacity: 0, x: -10, scale: 0.95 },
|
||||
animate: { opacity: 1, x: 0, scale: 1 },
|
||||
className: "left-full top-1/2 -translate-y-1/2 ml-2",
|
||||
},
|
||||
};
|
||||
|
||||
export const Popover: React.FC<PopoverProps> = ({
|
||||
title,
|
||||
content,
|
||||
position = "right",
|
||||
trigger = "hover",
|
||||
children,
|
||||
hoverDelay = 200,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hoverTimeout, setHoverTimeout] = useState<number | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (trigger === "hover") {
|
||||
if (hoverTimeout) window.clearTimeout(hoverTimeout);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === "hover") {
|
||||
// window.setTimeout 返回 number 类型
|
||||
const timeout = window.setTimeout(
|
||||
() => setIsOpen(false),
|
||||
hoverDelay
|
||||
);
|
||||
setHoverTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (trigger === "click") {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (trigger === "focus") {
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (trigger === "focus") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 添加类型断言来处理 children 的类型
|
||||
const childrenWithProps = isValidElement(children)
|
||||
? cloneElement(children as ReactElement<any>, {
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
handleClick(e);
|
||||
const child = children as ReactElement<{
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}>;
|
||||
if (child.props.onClick) {
|
||||
child.props.onClick(e);
|
||||
}
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
handleMouseEnter();
|
||||
const child = children as ReactElement<{
|
||||
onMouseEnter?: (e: React.MouseEvent) => void;
|
||||
}>;
|
||||
if (child.props.onMouseEnter) {
|
||||
child.props.onMouseEnter(e);
|
||||
}
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
handleMouseLeave();
|
||||
const child = children as ReactElement<{
|
||||
onMouseLeave?: (e: React.MouseEvent) => void;
|
||||
}>;
|
||||
if (child.props.onMouseLeave) {
|
||||
child.props.onMouseLeave(e);
|
||||
}
|
||||
},
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
handleFocus();
|
||||
const child = children as ReactElement<{
|
||||
onFocus?: (e: React.FocusEvent) => void;
|
||||
}>;
|
||||
if (child.props.onFocus) {
|
||||
child.props.onFocus(e);
|
||||
}
|
||||
},
|
||||
onBlur: (e: React.FocusEvent) => {
|
||||
handleBlur();
|
||||
const child = children as ReactElement<{
|
||||
onBlur?: (e: React.FocusEvent) => void;
|
||||
}>;
|
||||
if (child.props.onBlur) {
|
||||
child.props.onBlur(e);
|
||||
}
|
||||
},
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}>
|
||||
{childrenWithProps}
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={positionStyles[position].initial}
|
||||
animate={positionStyles[position].animate}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className={`
|
||||
absolute z-50 min-w-[200px] bg-white
|
||||
rounded-lg shadow-lg border border-gray-200
|
||||
${positionStyles[position].className}
|
||||
`}>
|
||||
{title && (
|
||||
<div className="px-4 py-2 border-b border-gray-100">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">{content}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
import "plyr/dist/plyr.css";
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
export const LoadingOverlay = () => {
|
||||
const { loadingProgress } = useContext(VideoPlayerContext);
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm z-10">
|
||||
<div className="text-white text-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
className="w-12 h-12 border-4 border-white border-t-transparent rounded-full mb-4"
|
||||
/>
|
||||
<p className="text-sm font-medium">
|
||||
{loadingProgress > 0
|
||||
? `加载中... ${loadingProgress}%`
|
||||
: "准备中..."}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
export default LoadingOverlay;
|
|
@ -0,0 +1,276 @@
|
|||
import React, { useEffect, useRef, useContext, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
SpeakerWaveIcon,
|
||||
SpeakerXMarkIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ArrowsPointingInIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import "plyr/dist/plyr.css";
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
import { formatTime } from "./utlis";
|
||||
import { PlaybackSpeed } from "./type";
|
||||
|
||||
export const Controls = () => {
|
||||
const {
|
||||
showControls,
|
||||
setShowControls,
|
||||
isSettingsOpen,
|
||||
setIsSettingsOpen,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
videoRef,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
|
||||
bufferingState,
|
||||
setBufferingState,
|
||||
volume,
|
||||
setVolume,
|
||||
isMuted,
|
||||
setIsMuted,
|
||||
loadingProgress,
|
||||
setLoadingProgress,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
duration,
|
||||
setDuration,
|
||||
brightness,
|
||||
setBrightness,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isHovering,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
} = useContext(VideoPlayerContext);
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = (e.clientX - rect.left) / rect.width;
|
||||
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||||
};
|
||||
// 控制栏显示逻辑
|
||||
useEffect(() => {
|
||||
let timer: number;
|
||||
|
||||
if (!isHovering && !isDragging) {
|
||||
timer = window.setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
};
|
||||
}, [isHovering, isDragging]);
|
||||
return (
|
||||
<motion.div
|
||||
layoutId="video-controls"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: showControls ? 1 : 0,
|
||||
y: showControls ? 0 : 20,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="relative h-1 mb-4 cursor-pointer group"
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true);
|
||||
handleProgressClick(e);
|
||||
}}>
|
||||
{/* 背景条 */}
|
||||
<div className="absolute w-full h-full bg-black/80 rounded-full" />
|
||||
{/* 播放进度 */}
|
||||
<motion.div
|
||||
className="absolute h-full bg-primary-500 rounded-full"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ type: "tween" }}
|
||||
/>
|
||||
{/* 进度球 */}
|
||||
<motion.div
|
||||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primary shadow-lg
|
||||
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||
style={{
|
||||
left: `${(currentTime / duration) * 100}%`,
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
{/* 预览进度 */}
|
||||
<motion.div
|
||||
className="absolute h-full bg-white/30 rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
||||
style={{
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
transform: "scaleY(2.5)",
|
||||
transformOrigin: "center",
|
||||
}}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮区域 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 播放/暂停按钮 */}
|
||||
<button
|
||||
onClick={() =>
|
||||
videoRef.current?.paused
|
||||
? videoRef.current.play()
|
||||
: videoRef.current?.pause()
|
||||
}
|
||||
className="text-white hover:text-primary-400">
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 音量控制 */}
|
||||
<div className="group relative flex items-center">
|
||||
<button
|
||||
onClick={() => setIsMuted(!isMuted)}
|
||||
className="text-white hover:text-primary-400">
|
||||
{isMuted ? (
|
||||
<SpeakerXMarkIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<SpeakerWaveIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<div className="bg-black/80 rounded-lg p-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={(e) => {
|
||||
const newVolume = parseFloat(
|
||||
e.target.value
|
||||
);
|
||||
setVolume(newVolume);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.volume = newVolume;
|
||||
}
|
||||
}}
|
||||
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间显示 */}
|
||||
{duration && (
|
||||
<span className="text-white text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 右侧控制按钮 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 设置按钮 */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||
className="text-white hover:text-primary-400">
|
||||
<Cog6ToothIcon className="w-6 h-6" />
|
||||
</button>
|
||||
{/* 设置菜单 */}
|
||||
<AnimatePresence>
|
||||
{isSettingsOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
||||
{/* 倍速选择 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-white text-sm mb-2">
|
||||
播放速度
|
||||
</h3>
|
||||
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
||||
(speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => {
|
||||
setPlaybackSpeed(
|
||||
speed as PlaybackSpeed
|
||||
);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.playbackRate =
|
||||
speed;
|
||||
}
|
||||
}}
|
||||
className={`block w-full text-left px-2 py-1 text-sm ${
|
||||
playbackSpeed === speed
|
||||
? "text-primaryHover"
|
||||
: "text-white"
|
||||
}`}>
|
||||
{speed}x
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 亮度调节 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-white text-sm mb-2">
|
||||
亮度
|
||||
</h3>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={brightness}
|
||||
onChange={(e) =>
|
||||
setBrightness(
|
||||
parseFloat(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full accent-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 全屏按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current?.parentElement?.requestFullscreen();
|
||||
}
|
||||
}}
|
||||
className="text-white hover:text-primary-400">
|
||||
{document.fullscreenElement ? (
|
||||
<ArrowsPointingInIcon className="w-6 h-6" />
|
||||
) : (
|
||||
<ArrowsPointingOutIcon className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
export default Controls;
|
|
@ -0,0 +1,115 @@
|
|||
import React, { createContext, ReactNode, useRef, useState } from "react";
|
||||
import { PlaybackSpeed } from "./type";
|
||||
import VideoPlayerLayout from "./VideoPlayerLayout";
|
||||
|
||||
interface VideoPlayerContextType {
|
||||
src: string;
|
||||
poster?: string;
|
||||
onError?: (error: string) => void;
|
||||
showControls: boolean;
|
||||
setShowControls: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isSettingsOpen: boolean;
|
||||
setIsSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
playbackSpeed: PlaybackSpeed;
|
||||
setPlaybackSpeed: React.Dispatch<React.SetStateAction<PlaybackSpeed>>;
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
isReady: boolean;
|
||||
setIsReady: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isPlaying: boolean;
|
||||
setIsPlaying: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
error: string | null;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
bufferingState: boolean;
|
||||
setBufferingState: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
volume: number;
|
||||
setVolume: React.Dispatch<React.SetStateAction<number>>;
|
||||
isMuted: boolean;
|
||||
setIsMuted: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
loadingProgress: number;
|
||||
setLoadingProgress: React.Dispatch<React.SetStateAction<number>>;
|
||||
currentTime: number;
|
||||
setCurrentTime: React.Dispatch<React.SetStateAction<number>>;
|
||||
duration: number;
|
||||
setDuration: React.Dispatch<React.SetStateAction<number>>;
|
||||
brightness: number;
|
||||
setBrightness: React.Dispatch<React.SetStateAction<number>>;
|
||||
isDragging: boolean;
|
||||
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isHovering: boolean;
|
||||
setIsHovering: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
progressRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
export const VideoPlayerContext = createContext<VideoPlayerContextType | null>(
|
||||
null
|
||||
);
|
||||
export function VideoPlayer({
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
}: {
|
||||
src: string;
|
||||
poster?: string;
|
||||
onError?: (error: string) => void;
|
||||
}) {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1.0);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bufferingState, setBufferingState] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [loadingProgress, setLoadingProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [brightness, setBrightness] = useState(1);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider
|
||||
value={{
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
showControls,
|
||||
setShowControls,
|
||||
isSettingsOpen,
|
||||
setIsSettingsOpen,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
videoRef,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
error,
|
||||
setError,
|
||||
bufferingState,
|
||||
setBufferingState,
|
||||
volume,
|
||||
setVolume,
|
||||
isMuted,
|
||||
setIsMuted,
|
||||
loadingProgress,
|
||||
setLoadingProgress,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
duration,
|
||||
setDuration,
|
||||
brightness,
|
||||
setBrightness,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isHovering,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
}}>
|
||||
<VideoPlayerLayout></VideoPlayerLayout>
|
||||
</VideoPlayerContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { useContext } from "react";
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
import Controls from "./VideoControls";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { VideoScreen } from "./VideoScreen";
|
||||
import LoadingOverlay from "./LoadingOverlay";
|
||||
|
||||
export default function VideoPlayerLayout() {
|
||||
const {
|
||||
isReady,
|
||||
setIsHovering,
|
||||
setShowControls,
|
||||
showControls,
|
||||
isDragging,
|
||||
} = useContext(VideoPlayerContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative aspect-video w-full bg-black rounded-lg overflow-hidden`}
|
||||
onMouseEnter={() => {
|
||||
setIsHovering(true);
|
||||
setShowControls(true);
|
||||
}}>
|
||||
{!isReady && <div>123</div>}
|
||||
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
||||
<VideoScreen></VideoScreen>
|
||||
<AnimatePresence>
|
||||
{(showControls || isDragging) && <Controls />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
// VideoPlayer.tsx
|
||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import "plyr/dist/plyr.css";
|
||||
import { VideoPlayerContext } from "./VideoPlayer";
|
||||
|
||||
interface VideoScreenProps {
|
||||
autoPlay?: boolean;
|
||||
// className?: string;
|
||||
// qualities?: { label: string; value: string }[];
|
||||
// onQualityChange?: (quality: string) => void;
|
||||
}
|
||||
export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||
autoPlay = false,
|
||||
}) => {
|
||||
const {
|
||||
src,
|
||||
poster,
|
||||
onError,
|
||||
showControls,
|
||||
setShowControls,
|
||||
isSettingsOpen,
|
||||
setIsSettingsOpen,
|
||||
playbackSpeed,
|
||||
setPlaybackSpeed,
|
||||
videoRef,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
error,
|
||||
setError,
|
||||
bufferingState,
|
||||
setBufferingState,
|
||||
volume,
|
||||
setVolume,
|
||||
isMuted,
|
||||
setIsMuted,
|
||||
loadingProgress,
|
||||
setLoadingProgress,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
duration,
|
||||
setDuration,
|
||||
brightness,
|
||||
setBrightness,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
isHovering,
|
||||
setIsHovering,
|
||||
progressRef,
|
||||
} = useContext(VideoPlayerContext);
|
||||
// 处理进度条拖拽
|
||||
|
||||
const handleProgressDrag = (e: MouseEvent) => {
|
||||
if (!isDragging || !videoRef.current || !progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width)
|
||||
);
|
||||
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||||
};
|
||||
|
||||
// 添加拖拽事件监听
|
||||
useEffect(() => {
|
||||
const handleMouseUp = () => setIsDragging(false);
|
||||
const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e);
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
// 添加控制栏组件
|
||||
|
||||
useEffect(() => {
|
||||
let hls: Hls;
|
||||
|
||||
const initializeHls = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// Reset states
|
||||
setIsReady(false);
|
||||
setError(null);
|
||||
setLoadingProgress(0);
|
||||
setBufferingState(false);
|
||||
|
||||
// Check for native HLS support (Safari)
|
||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoRef.current.src = src;
|
||||
setIsReady(true);
|
||||
if (autoPlay) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
console.log("Auto-play prevented:", error);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
const errorMessage = "您的浏览器不支持 HLS 视频播放";
|
||||
setError(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 600,
|
||||
enableWorker: true,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(videoRef.current);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||
setIsReady(true);
|
||||
if (autoPlay && videoRef.current) {
|
||||
try {
|
||||
await videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
console.log("Auto-play prevented:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.BUFFER_APPENDING, () => {
|
||||
setBufferingState(true);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => {
|
||||
setBufferingState(false);
|
||||
if (data.stats) {
|
||||
const progress =
|
||||
(data.stats.loaded / data.stats.total) * 100;
|
||||
setLoadingProgress(Math.round(progress));
|
||||
}
|
||||
});
|
||||
let networkError;
|
||||
let fatalError;
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
networkError = `网络错误: ${data.details}`;
|
||||
console.error(networkError);
|
||||
setError(networkError);
|
||||
onError?.(networkError);
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error("Media error, attempting to recover");
|
||||
setError("视频解码错误,尝试恢复...");
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
fatalError = `加载失败: ${data.details}`;
|
||||
console.error(fatalError);
|
||||
setError(fatalError);
|
||||
onError?.(fatalError);
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
// Event handlers
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
const handleWaiting = () => setBufferingState(true);
|
||||
const handlePlaying = () => setBufferingState(false);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.addEventListener("play", handlePlay);
|
||||
videoRef.current.addEventListener("pause", handlePause);
|
||||
videoRef.current.addEventListener("ended", handleEnded);
|
||||
videoRef.current.addEventListener("waiting", handleWaiting);
|
||||
videoRef.current.addEventListener("playing", handlePlaying);
|
||||
}
|
||||
initializeHls();
|
||||
return () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.removeEventListener("play", handlePlay);
|
||||
videoRef.current.removeEventListener("pause", handlePause);
|
||||
videoRef.current.removeEventListener("ended", handleEnded);
|
||||
videoRef.current.removeEventListener("waiting", handleWaiting);
|
||||
videoRef.current.removeEventListener("playing", handlePlaying);
|
||||
}
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
}
|
||||
};
|
||||
}, [src, onError, autoPlay]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full"
|
||||
poster={poster}
|
||||
controls={false}
|
||||
playsInline
|
||||
muted={isMuted}
|
||||
style={{ filter: `brightness(${brightness})` }}
|
||||
onTimeUpdate={() => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
setDuration(videoRef.current.duration);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export type PlaybackSpeed = 0.5 | 0.75 | 1.0 | 1.25 | 1.5 | 2.0;
|
|
@ -0,0 +1,5 @@
|
|||
export const formatTime = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
|
@ -6,7 +6,6 @@
|
|||
@apply border-b-2 border-primaryHover;
|
||||
}
|
||||
|
||||
|
||||
.ant-popover-inner {
|
||||
padding: 0 !important;
|
||||
@apply border border-gray-300;
|
||||
|
@ -108,3 +107,37 @@
|
|||
.custom-table .ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none; /* 去除最后一行的底部边框 */
|
||||
}
|
||||
/* 自定义 Plyr 样式 */
|
||||
.plyr--full-ui input[type="range"] {
|
||||
color: #ff0000; /* YouTube 红色 */
|
||||
}
|
||||
|
||||
.plyr__control--overlaid {
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.plyr--video .plyr__control:hover {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-webkit-slider-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-moz-range-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
.plyr--full-ui input[type="range"]::-ms-thumb {
|
||||
background: #ff0000;
|
||||
}
|
||||
|
||||
/* 缓冲条样式 */
|
||||
.plyr__progress__buffer {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* 控制栏背景 */
|
||||
.plyr--video .plyr__controls {
|
||||
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { MainLayout } from "../components/layout/main/MainLayout";
|
|||
import StudentCoursesPage from "../app/main/courses/student/page";
|
||||
import InstructorCoursesPage from "../app/main/courses/instructor/page";
|
||||
import HomePage from "../app/main/home/page";
|
||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -53,29 +54,43 @@ export const routes: CustomRouteObject[] = [
|
|||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <HomePage />
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
children: [
|
||||
{
|
||||
path: "student",
|
||||
element: <WithAuth><StudentCoursesPage /></WithAuth>
|
||||
element: (
|
||||
<WithAuth>
|
||||
<StudentCoursesPage />
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "instructor",
|
||||
element: <WithAuth><InstructorCoursesPage /></WithAuth>
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
element: (
|
||||
<WithAuth>
|
||||
<InstructorCoursesPage />
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "course",
|
||||
children: [{
|
||||
children: [
|
||||
{
|
||||
path: ":id?/manage", // 使用 ? 表示 id 参数是可选的
|
||||
element: <CourseEditorPage />
|
||||
}]
|
||||
element: <CourseEditorPage />,
|
||||
},
|
||||
{
|
||||
path: ":id?/detail", // 使用 ? 表示 id 参数是可选的
|
||||
element: <CourseDetailPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
|
|
|
@ -65,3 +65,32 @@ export const messageDetailSelect: Prisma.MessageSelect = {
|
|||
option: true,
|
||||
intent: true,
|
||||
};
|
||||
export const courseDetailSelect: Prisma.CourseSelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
subTitle: true,
|
||||
description: true,
|
||||
thumbnail: true,
|
||||
level: true,
|
||||
requirements: true,
|
||||
objectives: true,
|
||||
skills: true,
|
||||
audiences: true,
|
||||
totalDuration: true,
|
||||
totalLectures: true,
|
||||
averageRating: true,
|
||||
numberOfReviews: true,
|
||||
numberOfStudents: true,
|
||||
completionRate: true,
|
||||
status: true,
|
||||
isFeatured: true,
|
||||
createdAt: true,
|
||||
publishedAt: true,
|
||||
// 关联表选择
|
||||
sections: {
|
||||
include: {
|
||||
lectures: true,
|
||||
},
|
||||
},
|
||||
enrollments: true,
|
||||
};
|
||||
|
|
|
@ -151,22 +151,22 @@ export type DepartmentDto = Department & {
|
|||
children: DepartmentDto[];
|
||||
hasChildren: boolean;
|
||||
staffs: StaffDto[];
|
||||
terms: TermDto[]
|
||||
terms: TermDto[];
|
||||
};
|
||||
export type RoleMapDto = RoleMap & {
|
||||
staff: StaffDto
|
||||
}
|
||||
staff: StaffDto;
|
||||
};
|
||||
export type SectionDto = Section & {
|
||||
lectures: Lecture[]
|
||||
}
|
||||
lectures: Lecture[];
|
||||
};
|
||||
export type CourseDto = Course & {
|
||||
enrollments: Enrollment[]
|
||||
}
|
||||
enrollments: Enrollment[];
|
||||
sections: SectionDto[];
|
||||
};
|
||||
export interface BaseSetting {
|
||||
appConfig?: {
|
||||
splashScreen?: string;
|
||||
devDept?: string;
|
||||
|
||||
};
|
||||
}
|
||||
export type RowModelResult = {
|
||||
|
|
|
@ -326,12 +326,18 @@ importers:
|
|||
framer-motion:
|
||||
specifier: ^11.15.0
|
||||
version: 11.15.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
hls.js:
|
||||
specifier: ^1.5.18
|
||||
version: 1.5.18
|
||||
idb-keyval:
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1
|
||||
mitt:
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1
|
||||
plyr-react:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0(plyr@3.7.8)(react@18.2.0)
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
|
@ -401,7 +407,7 @@ importers:
|
|||
version: 8.4.49
|
||||
tailwindcss:
|
||||
specifier: ^3.4.10
|
||||
version: 3.4.17(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2))
|
||||
version: 3.4.17(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2))
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.7.2
|
||||
|
@ -3568,6 +3574,9 @@ packages:
|
|||
copy-to-clipboard@3.3.3:
|
||||
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
|
||||
|
||||
core-js@3.39.0:
|
||||
resolution: {integrity: sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
|
@ -3630,6 +3639,9 @@ packages:
|
|||
custom-error-instance@2.1.1:
|
||||
resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==}
|
||||
|
||||
custom-event-polyfill@1.0.7:
|
||||
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==}
|
||||
|
||||
date-fns@2.30.0:
|
||||
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
|
||||
engines: {node: '>=0.11'}
|
||||
|
@ -3805,6 +3817,7 @@ packages:
|
|||
encoding-down@6.3.0:
|
||||
resolution: {integrity: sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==}
|
||||
engines: {node: '>=6'}
|
||||
deprecated: Superseded by abstract-level (https://github.com/Level/community#faq)
|
||||
|
||||
end-of-stream@1.4.4:
|
||||
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
|
||||
|
@ -4327,6 +4340,9 @@ packages:
|
|||
resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hls.js@1.5.18:
|
||||
resolution: {integrity: sha512-znxR+2jecWluu/0KOBqUcvVyAB5tLff10vjMGrpAlz1eFY+ZhF1bY3r82V+Bk7WJdk03iTjtja9KFFz5BrqjSA==}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
|
@ -4820,6 +4836,9 @@ packages:
|
|||
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
|
||||
loadjs@4.3.0:
|
||||
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
|
||||
|
||||
locate-path@3.0.0:
|
||||
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -5322,6 +5341,19 @@ packages:
|
|||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
plyr-react@5.3.0:
|
||||
resolution: {integrity: sha512-m36/HrpHwg1N2rq3E31E8/kpAH55vk6qHUg17MG4uu9jbWYxnkN39lLmZQwxW7/qpDPfW5aGUJ6R3u23V0R3zA==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
plyr: ^3.7.7
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
plyr@3.7.8:
|
||||
resolution: {integrity: sha512-yG/EHDobwbB/uP+4Bm6eUpJ93f8xxHjjk2dYcD1Oqpe1EcuQl5tzzw9Oq+uVAzd2lkM11qZfydSiyIpiB8pgdA==}
|
||||
|
||||
possible-typed-array-names@1.0.0:
|
||||
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
@ -5465,6 +5497,9 @@ packages:
|
|||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
rangetouch@2.0.1:
|
||||
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==}
|
||||
|
||||
raw-body@2.5.2:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
@ -5697,6 +5732,15 @@ packages:
|
|||
react: '>=16.9.0'
|
||||
react-dom: '>=16.9.0'
|
||||
|
||||
react-aptor@2.0.0:
|
||||
resolution: {integrity: sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
react-beautiful-dnd@13.1.1:
|
||||
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
|
||||
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
|
||||
|
@ -6483,6 +6527,9 @@ packages:
|
|||
url-parse@1.5.10:
|
||||
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||
|
||||
url-polyfill@1.1.12:
|
||||
resolution: {integrity: sha512-mYFmBHCapZjtcNHW0MDq9967t+z4Dmg5CJ0KqysK3+ZbyoNOWQHksGCTWwDhxGXllkWlOc10Xfko6v4a3ucM6A==}
|
||||
|
||||
use-memo-one@1.1.3:
|
||||
resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==}
|
||||
peerDependencies:
|
||||
|
@ -10435,6 +10482,8 @@ snapshots:
|
|||
dependencies:
|
||||
toggle-selection: 1.0.6
|
||||
|
||||
core-js@3.39.0: {}
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cors@2.8.5:
|
||||
|
@ -10515,6 +10564,8 @@ snapshots:
|
|||
|
||||
custom-error-instance@2.1.1: {}
|
||||
|
||||
custom-event-polyfill@1.0.7: {}
|
||||
|
||||
date-fns@2.30.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.0
|
||||
|
@ -11305,6 +11356,8 @@ snapshots:
|
|||
|
||||
hexoid@1.0.0: {}
|
||||
|
||||
hls.js@1.5.18: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
|
@ -12005,6 +12058,8 @@ snapshots:
|
|||
|
||||
loader-runner@4.3.0: {}
|
||||
|
||||
loadjs@4.3.0: {}
|
||||
|
||||
locate-path@3.0.0:
|
||||
dependencies:
|
||||
p-locate: 3.0.0
|
||||
|
@ -12443,6 +12498,21 @@ snapshots:
|
|||
|
||||
pluralize@8.0.0: {}
|
||||
|
||||
plyr-react@5.3.0(plyr@3.7.8)(react@18.2.0):
|
||||
dependencies:
|
||||
plyr: 3.7.8
|
||||
react-aptor: 2.0.0(react@18.2.0)
|
||||
optionalDependencies:
|
||||
react: 18.2.0
|
||||
|
||||
plyr@3.7.8:
|
||||
dependencies:
|
||||
core-js: 3.39.0
|
||||
custom-event-polyfill: 1.0.7
|
||||
loadjs: 4.3.0
|
||||
rangetouch: 2.0.1
|
||||
url-polyfill: 1.1.12
|
||||
|
||||
possible-typed-array-names@1.0.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.4.49):
|
||||
|
@ -12457,7 +12527,7 @@ snapshots:
|
|||
camelcase-css: 2.0.1
|
||||
postcss: 8.4.49
|
||||
|
||||
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2)):
|
||||
postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2)):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
yaml: 2.4.5
|
||||
|
@ -12568,6 +12638,8 @@ snapshots:
|
|||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
rangetouch@2.0.1: {}
|
||||
|
||||
raw-body@2.5.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
|
@ -12896,6 +12968,10 @@ snapshots:
|
|||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
react-aptor@2.0.0(react@18.2.0):
|
||||
optionalDependencies:
|
||||
react: 18.2.0
|
||||
|
||||
react-beautiful-dnd@13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.0
|
||||
|
@ -13482,7 +13558,7 @@ snapshots:
|
|||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss@3.4.17(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2)):
|
||||
tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2)):
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
|
@ -13501,7 +13577,7 @@ snapshots:
|
|||
postcss: 8.4.49
|
||||
postcss-import: 15.1.0(postcss@8.4.49)
|
||||
postcss-js: 4.0.1(postcss@8.4.49)
|
||||
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.7.2))
|
||||
postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.6.13)(@types/node@20.14.10)(typescript@5.7.2))
|
||||
postcss-nested: 6.2.0(postcss@8.4.49)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.8
|
||||
|
@ -13792,6 +13868,8 @@ snapshots:
|
|||
querystringify: 2.2.0
|
||||
requires-port: 1.0.0
|
||||
|
||||
url-polyfill@1.1.12: {}
|
||||
|
||||
use-memo-one@1.1.3(react@18.2.0):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
|
Loading…
Reference in New Issue