完整的教务系统前端项目 - 包含所有修复和9月份数据
This commit is contained in:
221
src/components/ClassRank/index.css
Normal file
221
src/components/ClassRank/index.css
Normal file
@@ -0,0 +1,221 @@
|
||||
.module-class-rank {
|
||||
width: 373px;
|
||||
height: 100%;
|
||||
background-color: #fff7f1;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #fff;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 180px;
|
||||
height: 110px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background-image: url("@/assets/images/Rank/bg.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.module-class-rank-title {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
color: #262626;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-spin,
|
||||
.empty-data {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.module-class-rank-podium {
|
||||
width: 333px;
|
||||
height: 138px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
> li {
|
||||
width: 88px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 1),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
|
||||
.module-class-rank-podium-avatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -24px;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: -10px;
|
||||
width: 57px;
|
||||
height: 16px;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-podium-name {
|
||||
color: #1d2129;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 30px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
> i {
|
||||
height: 27px;
|
||||
background-size: 100% 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-podium-item1 {
|
||||
height: 98px;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/first_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.module-class-rank-podium-avatar {
|
||||
border-color: #ffc15b;
|
||||
|
||||
&::before {
|
||||
background-image: url("@/assets/images/Rank/icon1.png");
|
||||
}
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item2 {
|
||||
height: 80px;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/second_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.module-class-rank-podium-avatar {
|
||||
border-color: #9ab9e3;
|
||||
&::before {
|
||||
background-image: url("@/assets/images/Rank/icon2.png");
|
||||
}
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item3 {
|
||||
height: 70px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/third_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.module-class-rank-podium-avatar {
|
||||
border-color: #d7a770;
|
||||
&::before {
|
||||
background-image: url("@/assets/images/Rank/icon3.png");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.module-class-rank-list {
|
||||
width: 333px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.module-class-rank-list-item {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
|
||||
> em {
|
||||
margin-left: 5px;
|
||||
color: #86909c;
|
||||
font-size: 20px;
|
||||
font-family: "HarmonyOS_Sans_TC_Bold";
|
||||
}
|
||||
> p {
|
||||
font-size: 14px;
|
||||
height: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: #1d2129;
|
||||
margin-left: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
> span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #86909c;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/components/ClassRank/index.jsx
Normal file
81
src/components/ClassRank/index.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Avatar, Spin, Empty } from "@arco-design/web-react";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import "./index.css";
|
||||
|
||||
const positions = ["item2", "item1", "item3"];
|
||||
const icons = ["icon2", "icon1", "icon3"];
|
||||
|
||||
const Rank = ({ className, data, loading }) => {
|
||||
const rankings = data?.rankings?.slice(0, 6) || [];
|
||||
|
||||
// 安全处理领奖台学生,确保至少有3个位置
|
||||
const podiumStudents = [
|
||||
rankings[1] || null, // 第2名
|
||||
rankings[0] || null, // 第1名
|
||||
rankings[2] || null, // 第3名
|
||||
];
|
||||
|
||||
const listStudents = rankings.slice(3);
|
||||
|
||||
return (
|
||||
<div className={`module-class-rank ${className}`}>
|
||||
<p className="module-class-rank-title">
|
||||
<IconFont className="title-icon" src="recuUY5nNf7DWT" />
|
||||
<span>班级排名</span>
|
||||
</p>
|
||||
{loading ? (
|
||||
<Spin size={40} className="module-class-rank-spin" />
|
||||
) : !data || !data?.rankings || data?.rankings?.length === 0 ? (
|
||||
<Empty description="暂无排名数据" className="empty-data" />
|
||||
) : (
|
||||
<>
|
||||
<ul className="module-class-rank-podium">
|
||||
{podiumStudents.map((student, index) => {
|
||||
return student ? (
|
||||
<li
|
||||
key={student.studentId}
|
||||
className={`module-class-rank-podium-${positions[index]}`}
|
||||
>
|
||||
<Avatar className="module-class-rank-podium-avatar">
|
||||
{student.avatar ? (
|
||||
<img alt="avatar" src={student.avatar} />
|
||||
) : (
|
||||
student.studentName?.charAt(0) || "?"
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="module-class-rank-podium-name">
|
||||
{student.studentName || "未知"}
|
||||
</span>
|
||||
<i className={`module-class-rank-podium-${icons[index]}`}></i>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={`empty-${index}`}
|
||||
className={`module-class-rank-podium-${positions[index]} empty`}
|
||||
>
|
||||
<div className="module-class-rank-podium-placeholder">
|
||||
<span>-</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ul className="module-class-rank-list">
|
||||
{listStudents.map((student, index) => (
|
||||
<li
|
||||
key={student.studentId}
|
||||
className="module-class-rank-list-item"
|
||||
>
|
||||
<em className="module-class-rank-num">{index + 4}</em>
|
||||
<p>{student.studentName || "未知"}</p>
|
||||
<span>{student.score}分</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rank;
|
||||
227
src/components/CourseList/index.css
Normal file
227
src/components/CourseList/index.css
Normal file
@@ -0,0 +1,227 @@
|
||||
.course-list-wrapper {
|
||||
width: 304px;
|
||||
height: 798px;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 20px 16px;
|
||||
overflow-y: hidden;
|
||||
|
||||
.course-list-title {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: #1d2129;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
margin-bottom: 10px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 5px;
|
||||
width: 31px;
|
||||
height: 3px;
|
||||
background-image: url("@/assets/images/Common/title_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.course-list-content {
|
||||
width: 100%;
|
||||
height: 700px;
|
||||
overflow-y: auto;
|
||||
|
||||
.course-list {
|
||||
width: 100%;
|
||||
|
||||
/* 自定义折叠面板元素 */
|
||||
.course-list-item {
|
||||
width: 272px;
|
||||
margin-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
padding: 3px 0;
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 21px;
|
||||
border: none;
|
||||
|
||||
.arco-collapse-item-header {
|
||||
border-radius: 8px;
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
.arco-timeline-item {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 0;
|
||||
}
|
||||
.arco-collapse-item-content-expanded {
|
||||
background-color: #fff;
|
||||
}
|
||||
.arco-collapse-item-content-box {
|
||||
margin-top: 10px;
|
||||
padding: 0 0 0 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 自定义时间轴元素 */
|
||||
.time-line-dot-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Common/time_line_dot_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.time-line-clock-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-image: url("@/assets/images/Common/time_line_clock_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.time-line-lock-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-image: url("@/assets/images/Common/time_line_lock_icon.png");
|
||||
background-size: 100% 100%;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.time-line-item {
|
||||
width: 248px;
|
||||
height: 74px;
|
||||
background-color: #f2f3f5;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
|
||||
> p {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
> .time-line-item-info {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: #4e5969;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.finish {
|
||||
&::before {
|
||||
content: "已结束";
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
background-color: #c9cdd4;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #c9cdd4;
|
||||
}
|
||||
}
|
||||
|
||||
.not-started {
|
||||
&::before {
|
||||
content: "未开始";
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
background-color: #e8e8e8;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 1px solid #0275f2;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 60px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/CoursesVideoPlayer/living_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #f2f8ff;
|
||||
border: 1px solid #4080ff !important;
|
||||
box-shadow: 0 2px 8px rgba(64, 128, 255, 0.15);
|
||||
|
||||
p {
|
||||
color: #1d2129;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.pending {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
> p {
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.time-line-item-info {
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
|
||||
.coming {
|
||||
&::before {
|
||||
content: "即将开始";
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 60px;
|
||||
height: 20px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
border-radius: 12px;
|
||||
background-color: #f2f3f5;
|
||||
color: #ff7d00;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ff7d00;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src/components/CourseList/index.jsx
Normal file
167
src/components/CourseList/index.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Collapse, Timeline, Spin } from "@arco-design/web-react";
|
||||
import { getCourseLiveList } from "@/services/courseLive";
|
||||
import "./index.css";
|
||||
|
||||
const TimelineItem = Timeline.Item;
|
||||
const CollapseItem = Collapse.Item;
|
||||
|
||||
const CourseList = ({ className = "", onCourseClick }) => {
|
||||
const [courseLiveList, setCourseLiveList] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedCourseId, setSelectedCourseId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourseList();
|
||||
}, []);
|
||||
|
||||
const fetchCourseList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getCourseLiveList();
|
||||
if (res.success) {
|
||||
const courseList = res.data || [];
|
||||
setCourseLiveList(courseList);
|
||||
|
||||
// 设置默认选中今天的课程(如果有)
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
let foundTodayCourse = false;
|
||||
|
||||
for (const unit of courseList) {
|
||||
const todayCourse = unit.courses.find(c => c.date === todayStr);
|
||||
if (todayCourse) {
|
||||
setSelectedCourseId(todayCourse.courseId);
|
||||
// 触发课程选择事件
|
||||
if (onCourseClick) {
|
||||
onCourseClick({
|
||||
...todayCourse,
|
||||
unitName: unit.unitName
|
||||
});
|
||||
}
|
||||
foundTodayCourse = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有今天的课程,选中第一个current或upcoming的课程
|
||||
if (!foundTodayCourse) {
|
||||
for (const unit of courseList) {
|
||||
const activeCourse = unit.courses.find(c => c.current || c.upcoming);
|
||||
if (activeCourse) {
|
||||
setSelectedCourseId(activeCourse.courseId);
|
||||
if (onCourseClick) {
|
||||
onCourseClick({
|
||||
...activeCourse,
|
||||
unitName: unit.unitName
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取课程列表失败:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 判断课程状态
|
||||
const getCourseStatus = (course) => {
|
||||
if (course.completed) return "finish";
|
||||
if (course.current) return "active";
|
||||
|
||||
// 判断未来课程的具体状态
|
||||
if (course.upcoming) {
|
||||
const courseDate = new Date(course.date);
|
||||
const today = new Date();
|
||||
|
||||
// 重置时间部分只比较日期
|
||||
courseDate.setHours(0, 0, 0, 0);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const timeDiff = courseDate - today;
|
||||
const daysDiff = Math.floor(timeDiff / (24 * 60 * 60 * 1000));
|
||||
|
||||
// 未来7天内的课程显示为"即将开始"
|
||||
if (daysDiff > 0 && daysDiff <= 7) {
|
||||
return "coming";
|
||||
}
|
||||
// 7天后的课程显示为"未开始"
|
||||
return "not-started";
|
||||
}
|
||||
|
||||
// 默认状态
|
||||
return "pending";
|
||||
};
|
||||
|
||||
// 获取图标类型
|
||||
const getDotIcon = (course) => {
|
||||
if (course.completed) return <div className="time-line-dot-icon" />;
|
||||
if (course.current) return <div className="time-line-clock-icon" />;
|
||||
return <div className="time-line-lock-icon" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`${className} course-list-wrapper`}>
|
||||
<p className="course-list-title">课程列表</p>
|
||||
<div className="course-list-content">
|
||||
<Spin />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className} course-list-wrapper`}>
|
||||
<p className="course-list-title">课程列表</p>
|
||||
<div className="course-list-content">
|
||||
<Collapse
|
||||
lazyload
|
||||
className="course-list"
|
||||
bordered={false}
|
||||
expandIconPosition="right"
|
||||
defaultActiveKey={["1"]}
|
||||
>
|
||||
{courseLiveList.map((unit, index) => (
|
||||
<CollapseItem
|
||||
key={unit.unitId}
|
||||
header={unit.unitName}
|
||||
name={String(index + 1)}
|
||||
className="course-list-item"
|
||||
>
|
||||
<Timeline>
|
||||
{unit.courses.map((course) => (
|
||||
<TimelineItem
|
||||
key={course.courseId}
|
||||
dot={getDotIcon(course)}
|
||||
lineType="dashed"
|
||||
>
|
||||
<div
|
||||
className={`time-line-item ${getCourseStatus(course)} ${selectedCourseId === course.courseId ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedCourseId(course.courseId);
|
||||
onCourseClick && onCourseClick({ ...course, unitName: unit.unitName });
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<p>{course.courseName}</p>
|
||||
<div className="time-line-item-info">
|
||||
<span>{course.teacherName}</span>
|
||||
<span>{course.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</CollapseItem>
|
||||
))}
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseList;
|
||||
268
src/components/CoursesVideoPlayer/index.css
Normal file
268
src/components/CoursesVideoPlayer/index.css
Normal file
@@ -0,0 +1,268 @@
|
||||
.courses-video-player-wrapper {
|
||||
width: 836px;
|
||||
height: 798px;
|
||||
position: relative;
|
||||
|
||||
.video-lock-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.courses-video-player {
|
||||
width: 100%;
|
||||
height: 545px;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
|
||||
.courses-video-player-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
|
||||
> span {
|
||||
color: #2c7aff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
/* 添加以下样式来防止点击时背景变色 */
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.courses-video-player-header-title {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.courses-video-player-video {
|
||||
width: 100%;
|
||||
height: 478px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
.courses-video-player-info {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
height: 234px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 16px;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
background-image: linear-gradient(180deg, #daecff, #ffffff);
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
.courses-video-player-audience-info {
|
||||
width: 308px;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
border-right: 1px solid #f2f3f5;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.teacher-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
object-fit: cover;
|
||||
object-position: center 30%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 2%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 刘杰导师头像特殊调整 */
|
||||
.teacher-avatar.teacher-liujie {
|
||||
img {
|
||||
width: 400%; /* 大幅放大 */
|
||||
height: 400%;
|
||||
object-position: center 30%; /* 调整位置 */
|
||||
top: -100%; /* 继续大幅上移 */
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
.living-icon {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
bottom: -8px;
|
||||
width: 62px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/CoursesVideoPlayer/living_icon.png");
|
||||
background-size: 100% 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.teacher-name {
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.teacher-tag {
|
||||
background-color: #f2f3f5;
|
||||
box-sizing: border-box;
|
||||
padding: 1px 8px;
|
||||
border-radius: 2px;
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.living-data {
|
||||
width: 100%;
|
||||
height: 47px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.living-data-item {
|
||||
min-width: 78px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
> span:first-child {
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
> span:last-child {
|
||||
color: #1d2129;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.courses-video-player-teacher-info {
|
||||
width: 456px;
|
||||
height: 100%;
|
||||
margin-left: 20px;
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
line-height: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.teacher-introduce {
|
||||
width: 100%;
|
||||
min-height: 84px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #4e5969;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f2f3f5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #c9cdd4;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
.courses-video-player-teacher-introduce {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.courses-video-player-teacher-tags {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
|
||||
.teacher-tags {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
margin-top: 5px;
|
||||
|
||||
> li {
|
||||
box-sizing: border-box;
|
||||
padding: 2px 8px;
|
||||
background-color: #e5f1ff;
|
||||
color: #0077ff;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-right: 10px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/components/CoursesVideoPlayer/index.jsx
Normal file
148
src/components/CoursesVideoPlayer/index.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Avatar } from "@arco-design/web-react";
|
||||
import Locked from "@/components/Locked";
|
||||
import "./index.css";
|
||||
|
||||
export default ({ className = "", isLock = false, selectedCourse, teacherData, unitPosters }) => {
|
||||
const handleClickBtn = (item) => {
|
||||
console.log(item);
|
||||
};
|
||||
|
||||
// 获取当前课程的导师信息
|
||||
const currentTeacher = selectedCourse && teacherData
|
||||
? teacherData[selectedCourse.teacherName]
|
||||
: teacherData?.["魏立慧"] || {
|
||||
name: "魏立慧",
|
||||
introduction: "企业资深一线HR,专注于为求职者提供一对一的个性化指导。通过真实招聘视角,深入剖析个人优势与短板、传授面试技巧、规划职业定位与发展路径,帮助学生快速提升求职竞争力。求职策略以实用落地为核心,注重互动交流与角色定位,让学员在轻松氛围中获得直击痛点的求职策略。",
|
||||
specialties: ["深谙用人逻辑", "擅长挖掘优势", "沟通真诚自然", "点评直击要害"],
|
||||
avatar: "//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp",
|
||||
type: "企业资深HR"
|
||||
};
|
||||
|
||||
// 需要调整头像位置的导师
|
||||
const needsAdjustment = ["赵志强", "魏立慧", "郭建辉"].includes(currentTeacher.name);
|
||||
|
||||
// 根据导师设置不同的背景色 - 这些颜色提取自实际的PNG图片背景
|
||||
const getAvatarBackground = (name) => {
|
||||
const backgrounds = {
|
||||
"刘杰": "#E3E2E0", // 浅灰色
|
||||
"郭建辉": "#E0D9D3", // 米灰色
|
||||
"赵志强": "#E3E2E0", // 浅灰色
|
||||
"孙应战": "#E3E2E0", // 浅灰色
|
||||
"魏立慧": "#DCD8D4" // 灰褐色
|
||||
};
|
||||
return backgrounds[name] || "#E3E2E0";
|
||||
};
|
||||
|
||||
// 获取当前课程信息
|
||||
const courseName = selectedCourse?.courseName || "钢铁是怎样炼成的";
|
||||
const courseDate = selectedCourse?.date || "09.01";
|
||||
const unitName = selectedCourse?.unitName || "教育体系认知";
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (date) => {
|
||||
// 将 "2025-09-02" 格式转换为 "09.02"
|
||||
if (date && date.includes('-')) {
|
||||
const parts = date.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}.${parts[2]}`;
|
||||
}
|
||||
}
|
||||
// 将 "9/1" 格式转换为 "09.01"
|
||||
if (date && date.includes('/')) {
|
||||
const parts = date.split('/');
|
||||
if (parts.length === 2) {
|
||||
const month = parts[0].padStart(2, '0');
|
||||
const day = parts[1].padStart(2, '0');
|
||||
return `${month}.${day}`;
|
||||
}
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${className} courses-video-player-wrapper`}>
|
||||
{/* 直播板块 */}
|
||||
<div className="courses-video-player">
|
||||
{isLock ? (
|
||||
<Locked
|
||||
className="video-lock-wrapper"
|
||||
text="该板块将于「垂直能力提升」阶段启动后开放届时,请留意教务系统通知,您可在该板块进行线上
|
||||
1V1 求职策略定制"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="courses-video-player-header">
|
||||
<span onClick={() => handleClickBtn(1)}>< 上一集</span>
|
||||
<span className="courses-video-player-header-title">
|
||||
{courseName}
|
||||
</span>
|
||||
<span onClick={() => handleClickBtn(2)}>下一集 ></span>
|
||||
</div>
|
||||
<div className="courses-video-player-video">
|
||||
{/* 如果没有selectedCourse(公共课直播间)或者selectedCourse.current为true,都播放视频 */}
|
||||
{!selectedCourse || selectedCourse?.current ? (
|
||||
<video src="/live.mp4" autoPlay controls></video>
|
||||
) : (
|
||||
<img
|
||||
src={unitPosters?.[unitName] || unitPosters?.["岗位体系认知"]}
|
||||
alt={unitName}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="courses-video-player-info">
|
||||
{/* 直播观众信息 */}
|
||||
<div className="courses-video-player-audience-info">
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar
|
||||
className={`teacher-avatar ${needsAdjustment ? 'avatar-adjust' : ''} ${currentTeacher.name === '刘杰' ? 'teacher-liujie' : ''}`}
|
||||
style={{ backgroundColor: getAvatarBackground(currentTeacher.name) }}
|
||||
>
|
||||
<img
|
||||
alt="avatar"
|
||||
src={currentTeacher.avatar}
|
||||
/>
|
||||
</Avatar>
|
||||
{selectedCourse?.current && <div className="living-icon" />}
|
||||
</div>
|
||||
<span className="teacher-name">{currentTeacher.name}老师</span>
|
||||
<span className="teacher-tag">{unitName}</span>
|
||||
<div className="living-data">
|
||||
<div className="living-data-item">
|
||||
<span>开始</span>
|
||||
<span>{formatDateTime(courseDate)} - 14:00</span>
|
||||
</div>
|
||||
<div className="living-data-item">
|
||||
<span>时长</span>
|
||||
<span>60分钟</span>
|
||||
</div>
|
||||
<div className="living-data-item">
|
||||
<span>观看</span>
|
||||
<span>3000人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 直播教师信息 */}
|
||||
<div className="courses-video-player-teacher-info">
|
||||
<div className="courses-video-player-teacher-introduce">
|
||||
<p className="title icon1">导师介绍</p>
|
||||
<p className="teacher-introduce">
|
||||
{currentTeacher.introduction}
|
||||
</p>
|
||||
</div>
|
||||
<div className="courses-video-player-teacher-tags">
|
||||
<p className="title icon2">教师专长</p>
|
||||
<ul className="teacher-tags">
|
||||
{currentTeacher.specialties.map((specialty, index) => (
|
||||
<li key={index}>{specialty}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/components/EchartsProgress/index.jsx
Normal file
109
src/components/EchartsProgress/index.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts";
|
||||
|
||||
const EchartsProgress = ({
|
||||
percent = 0,
|
||||
strokeWidth = 20,
|
||||
progressColor = new echarts.graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{ offset: 0, color: "#0275F2" },
|
||||
{ offset: 1, color: "#389CFA" },
|
||||
]), // 进度条颜色,默认为渐变色
|
||||
backgroundColor = "#FFF", // 背景颜色
|
||||
}) => {
|
||||
const chartRef = useRef(null);
|
||||
const chartInstance = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化图表
|
||||
if (chartRef.current && !chartInstance.current) {
|
||||
chartInstance.current = echarts.init(chartRef.current);
|
||||
}
|
||||
|
||||
// 配置图表选项 - 得分环
|
||||
const option = {
|
||||
series: [
|
||||
{
|
||||
type: "gauge",
|
||||
startAngle: 90,
|
||||
radius: "100%",
|
||||
endAngle: -270,
|
||||
pointer: {
|
||||
show: false,
|
||||
},
|
||||
progress: {
|
||||
show: true,
|
||||
overlap: false,
|
||||
roundCap: true,
|
||||
clip: false,
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
color: progressColor, // 设置进度条颜色
|
||||
},
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: strokeWidth,
|
||||
color: [
|
||||
[1, backgroundColor], // 背景颜色
|
||||
],
|
||||
},
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
distance: 0,
|
||||
length: 10,
|
||||
},
|
||||
axisTick: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: false,
|
||||
distance: 50,
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: percent,
|
||||
detail: {
|
||||
valueAnimation: true,
|
||||
offsetCenter: ["0%", "0%"],
|
||||
},
|
||||
},
|
||||
],
|
||||
detail: {
|
||||
formatter: "{a|{value}%}\n{c| }\n{b|整体课程完成进度}",
|
||||
rich: {
|
||||
a: {
|
||||
color: "#0077FF",
|
||||
fontSize: 40,
|
||||
align: "center",
|
||||
},
|
||||
c: { color: "transparent", lineHeight: 20 }, // 透明占位行
|
||||
b: {
|
||||
color: "#4E5969",
|
||||
fontSize: 20,
|
||||
align: "center",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 设置图表选项
|
||||
chartInstance.current.setOption(option);
|
||||
|
||||
// 响应窗口大小变化
|
||||
const resizeHandler = () => {
|
||||
chartInstance.current.resize();
|
||||
};
|
||||
|
||||
window.addEventListener("resize", resizeHandler);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
};
|
||||
}, [backgroundColor, percent, progressColor, strokeWidth]);
|
||||
|
||||
return <div ref={chartRef} style={{ width: "18vw", height: "18vw" }} />;
|
||||
};
|
||||
|
||||
export default EchartsProgress;
|
||||
74
src/components/FileIcon/index.css
Normal file
74
src/components/FileIcon/index.css
Normal file
@@ -0,0 +1,74 @@
|
||||
.file-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-icon-fold {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 0 12px 12px 0;
|
||||
border-color: transparent #fff transparent transparent;
|
||||
}
|
||||
|
||||
.file-icon-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* PDF - 红色 */
|
||||
.file-icon-pdf {
|
||||
background: linear-gradient(135deg, #ff4d4d 0%, #cc0000 100%);
|
||||
}
|
||||
|
||||
.file-icon-pdf .file-icon-fold {
|
||||
border-right-color: #ffcccc;
|
||||
}
|
||||
|
||||
/* Word文档 - 蓝色 */
|
||||
.file-icon-doc {
|
||||
background: linear-gradient(135deg, #2196f3 0%, #1565c0 100%);
|
||||
}
|
||||
|
||||
.file-icon-doc .file-icon-fold {
|
||||
border-right-color: #bbdefb;
|
||||
}
|
||||
|
||||
/* PPT - 橙色 */
|
||||
.file-icon-ppt {
|
||||
background: linear-gradient(135deg, #ff9800 0%, #e65100 100%);
|
||||
}
|
||||
|
||||
.file-icon-ppt .file-icon-fold {
|
||||
border-right-color: #ffe0b2;
|
||||
}
|
||||
|
||||
/* Excel - 绿色 */
|
||||
.file-icon-excel {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
|
||||
}
|
||||
|
||||
.file-icon-excel .file-icon-fold {
|
||||
border-right-color: #c8e6c9;
|
||||
}
|
||||
|
||||
/* 默认 - 灰色 */
|
||||
.file-icon-default {
|
||||
background: linear-gradient(135deg, #9e9e9e 0%, #616161 100%);
|
||||
}
|
||||
|
||||
.file-icon-default .file-icon-fold {
|
||||
border-right-color: #e0e0e0;
|
||||
}
|
||||
48
src/components/FileIcon/index.jsx
Normal file
48
src/components/FileIcon/index.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import "./index.css";
|
||||
|
||||
const FileIcon = ({ type = "doc" }) => {
|
||||
const getIconClass = () => {
|
||||
switch(type) {
|
||||
case 'pdf':
|
||||
return 'file-icon-pdf';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'file-icon-doc';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'file-icon-ppt';
|
||||
case 'excel':
|
||||
case 'xlsx':
|
||||
return 'file-icon-excel';
|
||||
default:
|
||||
return 'file-icon-default';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconText = () => {
|
||||
switch(type) {
|
||||
case 'pdf':
|
||||
return 'PDF';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'DOC';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'PPT';
|
||||
case 'excel':
|
||||
case 'xlsx':
|
||||
return 'XLS';
|
||||
default:
|
||||
return 'FILE';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`file-icon ${getIconClass()}`}>
|
||||
<div className="file-icon-fold"></div>
|
||||
<div className="file-icon-text">{getIconText()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileIcon;
|
||||
12
src/components/IconFont/index.jsx
Normal file
12
src/components/IconFont/index.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
const baseUrl =
|
||||
"https://ddcz-1315997005.cos.ap-nanjing.myqcloud.com/static/img/teach_sys_icon/";
|
||||
|
||||
const IconFont = (props) => {
|
||||
const { className, src, onClick } = props;
|
||||
|
||||
return (
|
||||
<img src={`${baseUrl}${src}.png`} className={className} onClick={onClick} />
|
||||
);
|
||||
};
|
||||
|
||||
export default IconFont;
|
||||
27
src/components/InfiniteScroll/index.css
Normal file
27
src/components/InfiniteScroll/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.infinite-scroll-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-data {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
color: #999;
|
||||
}
|
||||
/* 新增加载中样式 */
|
||||
.loading-container {
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
103
src/components/InfiniteScroll/index.jsx
Normal file
103
src/components/InfiniteScroll/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Empty, Spin } from "@arco-design/web-react";
|
||||
import "./index.css";
|
||||
|
||||
const InfiniteScroll = ({
|
||||
loadMore,
|
||||
hasMore,
|
||||
children,
|
||||
threshold = 0.1, // 触发阈值,元素可见比例
|
||||
className = "",
|
||||
empty = false,
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const sentinelRef = useRef(null);
|
||||
const observerRef = useRef(null);
|
||||
const throttleRef = useRef(null); // 节流控制
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false); // 首次挂载
|
||||
|
||||
// 加载更多数据的处理函数(带节流)
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
|
||||
// 节流处理:500ms内只能触发一次
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
}
|
||||
|
||||
throttleRef.current = setTimeout(() => {
|
||||
setLoading(true);
|
||||
loadMore().finally(() => {
|
||||
setLoading(false);
|
||||
throttleRef.current = null;
|
||||
});
|
||||
}, 10);
|
||||
}, [hasMore, loadMore, loading]);
|
||||
|
||||
// 设置IntersectionObserver
|
||||
useEffect(() => {
|
||||
// 初始化观察器
|
||||
const options = {
|
||||
root: containerRef.current,
|
||||
rootMargin: "0px 0px 50px 0px", // 减少提前触发距离
|
||||
threshold,
|
||||
};
|
||||
|
||||
// 创建观察器实例
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
const [entry] = entries;
|
||||
if (entry.isIntersecting) {
|
||||
handleLoadMore();
|
||||
}
|
||||
}, options);
|
||||
|
||||
// 开始观察哨兵元素
|
||||
if (sentinelRef.current) {
|
||||
observerRef.current.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
// 初始加载检查 - 仅在组件首次挂载时执行
|
||||
if (hasMore && !loading && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
handleLoadMore();
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
}
|
||||
// 清理节流定时器
|
||||
if (throttleRef.current) {
|
||||
clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadMore, hasMore, threshold, loading, hasInitialized, handleLoadMore]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`infinite-scroll-container ${className}`}
|
||||
>
|
||||
{children}
|
||||
|
||||
{/* 哨兵元素 - 用于触发加载更多 */}
|
||||
<div ref={sentinelRef} className="sentinel-element"></div>
|
||||
|
||||
{!hasMore && empty && (
|
||||
<Empty description="暂无数据" className="empty-data" />
|
||||
)}
|
||||
{/* 滚动加载指示器 */}
|
||||
{loading && hasMore && (
|
||||
<div className="loading-container">
|
||||
<Spin size={20} />
|
||||
<span className="loading-text">加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfiniteScroll;
|
||||
21
src/components/Layout/index.css
Normal file
21
src/components/Layout/index.css
Normal file
@@ -0,0 +1,21 @@
|
||||
/* 布局相关样式 */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background-color: #f2f3f5;
|
||||
|
||||
.app-layout-spin {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
/* 主内容区域 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
transition: margin-left 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
41
src/components/Layout/index.jsx
Normal file
41
src/components/Layout/index.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Spin } from "@arco-design/web-react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { getLoginStudentInfo } from "@/services";
|
||||
import { setStudentInfo } from "@/store/slices/studentSlice";
|
||||
import Sidebar from "../Sidebar";
|
||||
import "./index.css";
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const globalLoading = useSelector(
|
||||
(state) => state?.loading?.effect?.globalLoading
|
||||
);
|
||||
const studentInfo = useSelector((state) => state.student.studentInfo);
|
||||
|
||||
const queryLoginStudentInfo = useCallback(async () => {
|
||||
const res = await getLoginStudentInfo();
|
||||
if (res.success) {
|
||||
dispatch(setStudentInfo(res.data));
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// 初始化项目统一获取登录用户信息
|
||||
useEffect(() => {
|
||||
if (!studentInfo) {
|
||||
queryLoginStudentInfo();
|
||||
}
|
||||
}, [queryLoginStudentInfo, studentInfo]);
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} />
|
||||
<Spin block loading={globalLoading} size={40} className="app-layout-spin">
|
||||
<main className="main-content">{children}</main>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
134
src/components/LiveSummary/index.css
Normal file
134
src/components/LiveSummary/index.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.live-summary-wrapper {
|
||||
width: 304px;
|
||||
height: 798px;
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
box-sizing: border-box;
|
||||
padding: 20px 16px;
|
||||
|
||||
.not-living {
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.live-summary-title {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: #1d2129;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
bottom: 5px;
|
||||
width: 31px;
|
||||
height: 3px;
|
||||
background-image: url("@/assets/images/Common/title_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.live-summary-list {
|
||||
width: 100%;
|
||||
max-height: 530px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
|
||||
.live-summary-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.live-summary-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.live-summary-item-title {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
padding-left: 25px;
|
||||
position: relative;
|
||||
line-height: 22px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #2c7aff;
|
||||
/* margin-bottom: 10px; */
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Common/title_dot_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.live-summary-item-content-list:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.live-summary-item-content-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
|
||||
.live-summary-item-content-item {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
box-sizing: border-box;
|
||||
background-color: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
line-height: 46px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #1d2129;
|
||||
text-align: left;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.job-search-strategy-btn {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 2px;
|
||||
background-color: #2c7aff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
70
src/components/LiveSummary/index.jsx
Normal file
70
src/components/LiveSummary/index.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { mockData } from "@/data/mockData";
|
||||
import "./index.css";
|
||||
|
||||
const LiveSummary = ({ className = "", showBtn = false, isLiving = true }) => {
|
||||
const navigate = useNavigate();
|
||||
const { jobStrategyNotes } = mockData;
|
||||
|
||||
const handleClickBtn = () => {
|
||||
navigate("/job-strategy-detail");
|
||||
};
|
||||
|
||||
// 按时间排序keyPoints(从早到晚)
|
||||
const sortedPoints = [...jobStrategyNotes.keyPoints].sort((a, b) => {
|
||||
const timeA = a.time.split(':').map(Number);
|
||||
const timeB = b.time.split(':').map(Number);
|
||||
return (timeA[0] * 60 + timeA[1]) - (timeB[0] * 60 + timeB[1]);
|
||||
});
|
||||
|
||||
// 根据type分组排序后的keyPoints
|
||||
const groupedPoints = sortedPoints.reduce((acc, point) => {
|
||||
const typeMap = {
|
||||
strategy: "策略建议",
|
||||
advice: "专家建议",
|
||||
technique: "核心技巧",
|
||||
timeline: "时间规划",
|
||||
qa: "答疑解惑"
|
||||
};
|
||||
|
||||
const groupName = typeMap[point.type] || point.type;
|
||||
|
||||
if (!acc[groupName]) {
|
||||
acc[groupName] = [];
|
||||
}
|
||||
acc[groupName].push(point);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={`${className} live-summary-wrapper`}>
|
||||
<p className={`live-summary-title ${!isLiving && "not-living"}`}>
|
||||
{jobStrategyNotes.title}
|
||||
</p>
|
||||
<ul className="live-summary-list">
|
||||
{Object.entries(groupedPoints).slice(0, 3).map(([groupName, points]) => (
|
||||
<li className="live-summary-item" key={groupName}>
|
||||
<p className="live-summary-item-title">{groupName}</p>
|
||||
<ul className="live-summary-item-content-list">
|
||||
{points.slice(0, 3).map((point) => (
|
||||
<li className="live-summary-item-content-item" key={point.id}>
|
||||
<span style={{fontWeight: "600"}}>{point.time}</span> - {point.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{showBtn && (
|
||||
<div
|
||||
className="job-search-strategy-btn"
|
||||
onClick={() => handleClickBtn()}
|
||||
>
|
||||
查看求职策略
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveSummary;
|
||||
34
src/components/Locked/index.css
Normal file
34
src/components/Locked/index.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.lock-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
|
||||
.lock {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background-image: url("@/assets/images/Common/lock_bg.png");
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 433px;
|
||||
height: 44px;
|
||||
text-align: center;
|
||||
color: #1d2129;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/components/Locked/index.jsx
Normal file
9
src/components/Locked/index.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import "./index.css";
|
||||
|
||||
export default ({ text = "", className = "" }) => {
|
||||
return (
|
||||
<div className={`lock-wrapper ${className}`}>
|
||||
<div className="lock">{text ? <span>{text}</span> : null}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
src/components/Modal/index.css
Normal file
42
src/components/Modal/index.css
Normal file
@@ -0,0 +1,42 @@
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5); /* 遮罩层颜色 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
40
src/components/Modal/index.jsx
Normal file
40
src/components/Modal/index.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect } from "react";
|
||||
import "./index.css";
|
||||
|
||||
const Modal = ({
|
||||
visible = false,
|
||||
onClose,
|
||||
children,
|
||||
className = "",
|
||||
maskClosable = true,
|
||||
}) => {
|
||||
// 防止背景滚动
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "auto";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
// 点击遮罩层关闭
|
||||
const handleMaskClick = (e) => {
|
||||
if (maskClosable && e.target.classList.contains("modal-mask")) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className={`modal-mask ${className}`} onClick={handleMaskClick}>
|
||||
<div className="modal-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
30
src/components/Portal/index.jsx
Normal file
30
src/components/Portal/index.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Portal = ({ children, className = "portal-container" }) => {
|
||||
const [container, setContainer] = useState(null);
|
||||
useEffect(() => {
|
||||
// 创建容器元素
|
||||
const portalContainer = document.createElement("div");
|
||||
portalContainer.className = className;
|
||||
|
||||
// 添加到body
|
||||
document.body.appendChild(portalContainer);
|
||||
setContainer(portalContainer);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (document.body.contains(portalContainer)) {
|
||||
document.body.removeChild(portalContainer);
|
||||
}
|
||||
};
|
||||
}, [className]);
|
||||
|
||||
// 只有容器存在时才渲染Portal
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
return createPortal(children, container);
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
227
src/components/Rank/index.css
Normal file
227
src/components/Rank/index.css
Normal file
@@ -0,0 +1,227 @@
|
||||
.module-class-rank {
|
||||
width: 360px;
|
||||
height: 413px;
|
||||
background-color: #fff7f1;
|
||||
border-radius: 16px;
|
||||
margin-right: 20px;
|
||||
border: 1px solid #fff;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 180px;
|
||||
height: 110px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background-image: url("@/assets/images/Rank/bg.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.module-class-rank-title {
|
||||
height: 30px;
|
||||
width: 100%;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
color: #262626;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-left: 30px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
content: "";
|
||||
background-image: url("@/assets/images/Rank/title_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-spin,
|
||||
.empty-data {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.module-class-rank-podium {
|
||||
width: 288px;
|
||||
height: 120px;
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
> li {
|
||||
width: 88px;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
|
||||
.module-class-rank-podium-avatar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -24px;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.module-class-rank-podium-name {
|
||||
color: #1d2129;
|
||||
font-size: 14px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 30px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
> i {
|
||||
height: 27px;
|
||||
background-size: 100% 100%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.module-class-rank-podium-icon1 {
|
||||
width: 14px;
|
||||
background-image: url("@/assets/images/Rank/1.png");
|
||||
}
|
||||
.module-class-rank-podium-icon2 {
|
||||
width: 27px;
|
||||
background-image: url("@/assets/images/Rank/2.png");
|
||||
}
|
||||
.module-class-rank-podium-icon3 {
|
||||
width: 26px;
|
||||
background-image: url("@/assets/images/Rank/3.png");
|
||||
}
|
||||
}
|
||||
|
||||
.module-class-rank-podium-item1 {
|
||||
height: 98px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 251, 238, 1),
|
||||
rgba(255, 251, 238, 0)
|
||||
);
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/first_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item2 {
|
||||
height: 80px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(238, 238, 238, 1),
|
||||
rgba(238, 238, 238, 0)
|
||||
);
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/second_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
.module-class-rank-podium-item3 {
|
||||
height: 70px;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 239, 230, 1),
|
||||
rgba(238, 238, 238, 0)
|
||||
);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("@/assets/images/Rank/third_icon.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.module-class-rank-list {
|
||||
width: 320px;
|
||||
height: 152px;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.module-class-rank-list-item {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
position: relative;
|
||||
|
||||
> i {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
.module-class-rank-list-item-icon4 {
|
||||
background-image: url("@/assets/images/Rank/icon4.png");
|
||||
}
|
||||
.module-class-rank-list-item-icon5 {
|
||||
background-image: url("@/assets/images/Rank/icon5.png");
|
||||
}
|
||||
.module-class-rank-list-item-icon6 {
|
||||
background-image: url("@/assets/images/Rank/icon6.png");
|
||||
}
|
||||
> p {
|
||||
font-size: 14px;
|
||||
height: 22px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
color: #616065;
|
||||
margin-left: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
> span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #616065;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/components/Rank/index.jsx
Normal file
77
src/components/Rank/index.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Avatar, Spin, Empty } from "@arco-design/web-react";
|
||||
import "./index.css";
|
||||
|
||||
const positions = ["item2", "item1", "item3"];
|
||||
const icons = ["icon2", "icon1", "icon3"];
|
||||
|
||||
const Rank = ({ className, data, loading }) => {
|
||||
const rankings = data?.rankings?.slice(0, 6) || [];
|
||||
|
||||
// 安全处理领奖台学生,确保至少有3个位置
|
||||
const podiumStudents = [
|
||||
rankings[1] || null, // 第2名
|
||||
rankings[0] || null, // 第1名
|
||||
rankings[2] || null, // 第3名
|
||||
];
|
||||
|
||||
const listStudents = rankings.slice(3);
|
||||
|
||||
return (
|
||||
<div className={`module-class-rank ${className}`}>
|
||||
<p className="module-class-rank-title">班级排名</p>
|
||||
{loading ? (
|
||||
<Spin size={40} className="module-class-rank-spin" />
|
||||
) : !data || !data?.rankings || data?.rankings?.length === 0 ? (
|
||||
<Empty description="暂无排名数据" className="empty-data" />
|
||||
) : (
|
||||
<>
|
||||
<ul className="module-class-rank-podium">
|
||||
{podiumStudents.map((student, index) => {
|
||||
return student ? (
|
||||
<li
|
||||
key={student.studentId}
|
||||
className={`module-class-rank-podium-${positions[index]}`}
|
||||
>
|
||||
<Avatar className="module-class-rank-podium-avatar">
|
||||
{student.avatar ? (
|
||||
<img alt="avatar" src={student.avatar} />
|
||||
) : (
|
||||
student.studentName?.charAt(0) || "?"
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="module-class-rank-podium-name">
|
||||
{student.studentName || "未知"}
|
||||
</span>
|
||||
<i className={`module-class-rank-podium-${icons[index]}`}></i>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={`empty-${index}`}
|
||||
className={`module-class-rank-podium-${positions[index]} empty`}
|
||||
>
|
||||
<div className="module-class-rank-podium-placeholder">
|
||||
<span>-</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<ul className="module-class-rank-list">
|
||||
{listStudents.map((student, index) => (
|
||||
<li
|
||||
key={student.studentId}
|
||||
className="module-class-rank-list-item"
|
||||
>
|
||||
<i className={`module-class-rank-list-item-icon${index + 4}`} />
|
||||
<p>{student.studentName || "未知"}</p>
|
||||
<span>{student.score}分</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rank;
|
||||
246
src/components/Sidebar/index.css
Normal file
246
src/components/Sidebar/index.css
Normal file
@@ -0,0 +1,246 @@
|
||||
.sidebar-expand-wrapper {
|
||||
width: 240px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
transition: width 0.3s ease;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background-color: #fff;
|
||||
|
||||
.sidebar-title {
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 20px;
|
||||
|
||||
> img {
|
||||
width: 143px;
|
||||
height: 48px;
|
||||
margin-right: 10px;
|
||||
transition: margin 0.3s ease;
|
||||
}
|
||||
}
|
||||
.user-info-wrapper {
|
||||
width: 208px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: #f4f7f9;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
> span {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 16px;
|
||||
color: #1d2129;
|
||||
}
|
||||
.user-id {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
}
|
||||
}
|
||||
.visitor-count {
|
||||
width: 208px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
background-color: #e5f1ff;
|
||||
position: relative;
|
||||
margin-top: 10px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 10px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url("@/assets/images/Sidebar/visitor_count_bg.png");
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.arco-statistic-value {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 41px;
|
||||
}
|
||||
}
|
||||
.sidebar-menu {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px 60px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.sidebar-menu-item-wrapper {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #e4ecf2;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar-menu-title {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
text-align: left;
|
||||
line-height: 42px;
|
||||
color: #4e5969;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-menu-item-active {
|
||||
background-image: url("@/assets/images/Sidebar/sidebar_menu_item_active_bg .png");
|
||||
background-size: 100% 100%;
|
||||
|
||||
.sidebar-menu-icon {
|
||||
color: #fff !important;
|
||||
}
|
||||
.sidebar-menu-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu-item {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
.sidebar-menu-icon {
|
||||
margin: 0 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.sidebar-menu-text {
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #616065;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #e4ecf2;
|
||||
box-sizing: border-box;
|
||||
padding-left: 20px;
|
||||
|
||||
> img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-retract-wrapper {
|
||||
width: 84px;
|
||||
|
||||
.sidebar-title {
|
||||
> img {
|
||||
margin: 0;
|
||||
transition: margin 0.3s ease;
|
||||
}
|
||||
> p {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
.user-info-wrapper {
|
||||
background-color: transparent;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
.user-avatar {
|
||||
margin-right: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
.visitor-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
.sidebar-menu-title {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-menu-item-wrapper {
|
||||
.sidebar-menu-item {
|
||||
justify-content: center;
|
||||
|
||||
.sidebar-menu-icon {
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-menu-text {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
width: 80px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
89
src/components/Sidebar/index.jsx
Normal file
89
src/components/Sidebar/index.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { Statistic } from "@arco-design/web-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import ICON from "@/assets/images/Sidebar/sidebar_icon.png";
|
||||
import ICONRETRACT from "@/assets/images/Sidebar/logo.png";
|
||||
import BTNICON from "@/assets/images/Sidebar/btn_icon.png";
|
||||
import routes from "@/routes";
|
||||
import "./index.css";
|
||||
|
||||
const Sidebar = ({ isCollapsed, setIsCollapsed }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const studentInfo = useSelector((state) => state.student.studentInfo);
|
||||
const handleNavClick = (path) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
// 切换侧边栏展开/折叠状态
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${
|
||||
!isCollapsed
|
||||
? "sidebar-retract-wrapper sidebar-expand-wrapper"
|
||||
: "sidebar-expand-wrapper"
|
||||
}`}
|
||||
>
|
||||
<div className="sidebar-title">
|
||||
<img src={isCollapsed ? ICON : ICONRETRACT} alt="icon" />
|
||||
</div>
|
||||
<div className="user-info-wrapper">
|
||||
<img
|
||||
alt="avatar"
|
||||
className="user-avatar"
|
||||
src="//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp"
|
||||
/>
|
||||
{isCollapsed && (
|
||||
<div className="user-info">
|
||||
<span className="user-name">{studentInfo?.realName}</span>
|
||||
<span className="user-id">学号:{studentInfo?.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Statistic
|
||||
className="visitor-count"
|
||||
groupSeparator
|
||||
value={125670}
|
||||
prefix="访客总数:"
|
||||
/>
|
||||
<ul className="sidebar-menu">
|
||||
{routes
|
||||
.filter((item) => item.showMenu)
|
||||
?.map((item) => (
|
||||
<div key={item.key} className="sidebar-menu-item-wrapper">
|
||||
<p className="sidebar-menu-title">{item.name}</p>
|
||||
{item.routes
|
||||
?.filter((i) => i.showMenuItem)
|
||||
?.map((j) => (
|
||||
<li
|
||||
className={
|
||||
location.pathname === j.path
|
||||
? "sidebar-menu-item-active sidebar-menu-item"
|
||||
: "sidebar-menu-item"
|
||||
}
|
||||
key={j.path}
|
||||
onClick={() => handleNavClick(j.path)}
|
||||
>
|
||||
<IconFont
|
||||
className="sidebar-menu-icon"
|
||||
src={location.pathname === j.path ? j.active : j.default}
|
||||
/>
|
||||
<span className="sidebar-menu-text">{j.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
<div className="sidebar-btn" onClick={toggleSidebar}>
|
||||
<img src={BTNICON} alt="btn" className="sidebar-btn-icon" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
143
src/components/StageProgress/index.css
Normal file
143
src/components/StageProgress/index.css
Normal file
@@ -0,0 +1,143 @@
|
||||
.stage-progress-wrapper {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
padding: 0 20px;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
|
||||
.stage-progress-item1 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 257px;
|
||||
height: 64px;
|
||||
background-size: 100% 100%;
|
||||
margin-right: -10px;
|
||||
background-image: url("@/assets/images/StageProgress/step1_active.png");
|
||||
background-size: 100%;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stage-progress-item4 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 257px;
|
||||
height: 64px;
|
||||
background-image: url("@/assets/images/StageProgress/step4_default.png");
|
||||
background-size: 100% 100%;
|
||||
color: #86909c;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stage-progress-item4-active {
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
background-image: url("@/assets/images/StageProgress/step4_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-item {
|
||||
flex-shrink: 0;
|
||||
width: 257px;
|
||||
height: 64px;
|
||||
background-image: url("@/assets/images/StageProgress/step_default.png");
|
||||
background-size: 100% 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #86909c;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stage-progress-item-active {
|
||||
color: #ffffff;
|
||||
background-image: url("@/assets/images/StageProgress/step_active.png");
|
||||
}
|
||||
|
||||
.stage-progress-item-step {
|
||||
width: 45px;
|
||||
height: 22px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 6px;
|
||||
background-color: #c9cdd4;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
font-family: "Unitext";
|
||||
}
|
||||
|
||||
.stage-progress-item-step-active {
|
||||
background-color: #fff !important;
|
||||
color: #0077ff !important;
|
||||
}
|
||||
|
||||
.stage-progress-item-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #86909c;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.stage-progress-item-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.stage-progress-item-icon2 {
|
||||
width: 5px;
|
||||
height: 10px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.stage-progress-item-text-active {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.stage-progress-text {
|
||||
cursor: pointer;
|
||||
width: 150px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
> span {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 24px;
|
||||
height: 0px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: -20px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px dashed #86909c;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 24px;
|
||||
height: 0px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -45px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px dashed #86909c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/components/StageProgress/index.jsx
Normal file
49
src/components/StageProgress/index.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import "./index.css";
|
||||
|
||||
const StageProgress = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClickStar = () => {
|
||||
navigate("/career-tree");
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className="stage-progress-wrapper">
|
||||
<li className="stage-progress-item1">
|
||||
<span className="stage-progress-item-step stage-progress-item-step-active">
|
||||
step1
|
||||
</span>
|
||||
<span className="stage-progress-item-text stage-progress-item-text-active">
|
||||
生涯起航
|
||||
</span>
|
||||
<IconFont src="recuUY5n0dIJKu" className="stage-progress-item-icon" />
|
||||
</li>
|
||||
<li className="stage-progress-item">
|
||||
<span className="stage-progress-item-step">step2</span>
|
||||
<span className="stage-progress-item-text">能力跃升</span>
|
||||
<IconFont src="recuUY5kKp4Qc5" className="stage-progress-item-icon" />
|
||||
</li>
|
||||
<li className="stage-progress-text" onClick={handleClickStar}>
|
||||
<span>垂直方向选择</span>
|
||||
</li>
|
||||
<li className="stage-progress-item">
|
||||
<span className="stage-progress-item-step">step3</span>
|
||||
<span className="stage-progress-item-text">垂直精进</span>
|
||||
<IconFont src="recuUY5qlmzVhH" className="stage-progress-item-icon" />
|
||||
<IconFont src="recuUY5xdpLNXn" className="stage-progress-item-icon2" />
|
||||
<IconFont src="recuUY5joxSk5C" className="stage-progress-item-icon" />
|
||||
</li>
|
||||
<li className="stage-progress-item4 ">
|
||||
<span className="stage-progress-item-step">step4</span>
|
||||
<span className="stage-progress-item-text">决胜求职</span>
|
||||
<IconFont src="recuUY5lTOco3Q" className="stage-progress-item-icon" />
|
||||
<IconFont src="recuUY5xdpLNXn" className="stage-progress-item-icon2" />
|
||||
<IconFont src="recuUY5luVMCPc" className="stage-progress-item-icon" />
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default StageProgress;
|
||||
147
src/components/Toast/README.md
Normal file
147
src/components/Toast/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Toast 组件使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
自定义的全局 Toast 组件,支持命令式调用,无需在组件树中添加 Provider。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🚀 轻量级,零依赖
|
||||
- 📱 响应式设计
|
||||
- 🎨 支持多种类型(success, error, warning, info)
|
||||
- ⚡ 命令式 API,简单易用
|
||||
- 🔧 高度可定制
|
||||
- 🌙 支持暗色主题
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 基本用法
|
||||
|
||||
```jsx
|
||||
import toast from "@/components/Toast";
|
||||
|
||||
// 成功提示
|
||||
toast.success("操作成功!");
|
||||
|
||||
// 错误提示
|
||||
toast.error("操作失败!");
|
||||
|
||||
// 警告提示
|
||||
toast.warning("请注意!");
|
||||
|
||||
// 信息提示
|
||||
toast.info("这是一条信息");
|
||||
```
|
||||
|
||||
### 2. 高级用法
|
||||
|
||||
```jsx
|
||||
// 自定义持续时间
|
||||
toast.success("3秒后消失", { duration: 3000 });
|
||||
|
||||
// 永不自动消失
|
||||
toast.info("手动关闭", { duration: 0 });
|
||||
|
||||
// 禁用关闭按钮
|
||||
toast.error("无法关闭", { closable: false });
|
||||
|
||||
// 带标题的提示
|
||||
toast.success("操作完成", {
|
||||
title: "成功",
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
// 获取 Toast ID,手动关闭
|
||||
const toastId = toast.info("loading...");
|
||||
setTimeout(() => {
|
||||
toast.remove(toastId);
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
### 3. 在组件中使用 Hook(可选)
|
||||
|
||||
如果需要在组件内部管理 Toast 状态,可以使用 Provider 方式:
|
||||
|
||||
```jsx
|
||||
import { ToastProvider, useToast } from "@/components/Toast";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<YourComponent />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function YourComponent() {
|
||||
const { addToast, removeAllToasts } = useToast();
|
||||
|
||||
const handleClick = () => {
|
||||
addToast({
|
||||
type: "success",
|
||||
message: "这是通过 Hook 创建的 Toast",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleClick}>显示 Toast</button>
|
||||
<button onClick={removeAllToasts}>清空所有 Toast</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### 全局方法
|
||||
|
||||
- `toast.success(message, options?)` - 显示成功提示
|
||||
- `toast.error(message, options?)` - 显示错误提示
|
||||
- `toast.warning(message, options?)` - 显示警告提示
|
||||
- `toast.info(message, options?)` - 显示信息提示
|
||||
- `toast.remove(id)` - 移除指定 Toast
|
||||
- `toast.removeAll()` - 移除所有 Toast
|
||||
|
||||
### Options 参数
|
||||
|
||||
```typescript
|
||||
interface ToastOptions {
|
||||
title?: string; // 标题
|
||||
duration?: number; // 持续时间(毫秒),0 表示不自动关闭
|
||||
closable?: boolean; // 是否显示关闭按钮,默认 true
|
||||
type?: "success" | "error" | "warning" | "info"; // Toast 类型
|
||||
}
|
||||
```
|
||||
|
||||
### Hook API
|
||||
|
||||
- `useToast()` - 返回 Toast 管理方法
|
||||
- `addToast(options)` - 添加 Toast
|
||||
- `removeToast(id)` - 移除指定 Toast
|
||||
- `removeAllToasts()` - 移除所有 Toast
|
||||
- `toasts` - 当前 Toast 列表
|
||||
|
||||
## 样式定制
|
||||
|
||||
可以通过修改 CSS 变量来定制样式:
|
||||
|
||||
```css
|
||||
.toast-item {
|
||||
/* 自定义背景色 */
|
||||
background: your-color;
|
||||
|
||||
/* 自定义圆角 */
|
||||
border-radius: your-radius;
|
||||
|
||||
/* 自定义阴影 */
|
||||
box-shadow: your-shadow;
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. Toast 容器会自动创建和销毁,无需手动管理
|
||||
2. 支持同时显示多个 Toast
|
||||
3. 在移动端会自动适配屏幕宽度
|
||||
4. 支持暗色主题自动切换
|
||||
178
src/components/Toast/index.css
Normal file
178
src/components/Toast/index.css
Normal file
@@ -0,0 +1,178 @@
|
||||
/* Toast 容器 */
|
||||
.toast-container,
|
||||
.global-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-toast-container .toast-container {
|
||||
position: static;
|
||||
}
|
||||
|
||||
/* Toast 项目 */
|
||||
.toast-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 6px 16px -8px rgba(0, 0, 0, 0.08),
|
||||
0 9px 28px 0 rgba(0, 0, 0, 0.05),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12);
|
||||
border-left: 4px solid #d9d9d9;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 显示状态 */
|
||||
.toast-item.toast-visible {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 移除状态 */
|
||||
.toast-item.toast-removing {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Toast 类型样式 */
|
||||
.toast-item.toast-success {
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.toast-item.toast-success .toast-icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.toast-item.toast-error {
|
||||
border-left-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.toast-item.toast-error .toast-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.toast-item.toast-warning {
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.toast-item.toast-warning .toast-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.toast-item.toast-info {
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.toast-item.toast-info .toast-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
|
||||
/* Toast 内容 */
|
||||
.toast-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
.toast-close {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #bfbfbf;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: #8c8c8c;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 悬停效果 */
|
||||
.toast-item:hover {
|
||||
box-shadow: 0 6px 16px -8px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.toast-container,
|
||||
.global-toast-container {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗色主题支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toast-item {
|
||||
background: #1f1f1f;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 6px 16px -8px rgba(0, 0, 0, 0.32),
|
||||
0 9px 28px 0 rgba(0, 0, 0, 0.24),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.toast-close:hover {
|
||||
color: #bfbfbf;
|
||||
background: #262626;
|
||||
}
|
||||
}
|
||||
259
src/components/Toast/index.jsx
Normal file
259
src/components/Toast/index.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect, createContext, useContext } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
// Toast 上下文
|
||||
const ToastContext = createContext();
|
||||
|
||||
// Toast 类型
|
||||
const TOAST_TYPES = {
|
||||
SUCCESS: "success",
|
||||
ERROR: "error",
|
||||
WARNING: "warning",
|
||||
INFO: "info",
|
||||
};
|
||||
|
||||
// // Toast 图标
|
||||
// const TOAST_ICONS = {
|
||||
// [TOAST_TYPES.SUCCESS]: "✓",
|
||||
// [TOAST_TYPES.ERROR]: "✕",
|
||||
// [TOAST_TYPES.WARNING]: "⚠",
|
||||
// [TOAST_TYPES.INFO]: "ℹ",
|
||||
// };
|
||||
|
||||
// 单个 Toast 组件
|
||||
const ToastItem = ({ toast, onRemove }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isRemoving, setIsRemoving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 显示动画
|
||||
const showTimer = setTimeout(() => setIsVisible(true), 10);
|
||||
|
||||
// 自动移除
|
||||
const removeTimer = setTimeout(() => {
|
||||
handleRemove();
|
||||
}, toast.duration || 3000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(showTimer);
|
||||
clearTimeout(removeTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRemove = () => {
|
||||
setIsRemoving(true);
|
||||
setTimeout(() => {
|
||||
onRemove(toast.id);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`toast-item toast-${toast.type} ${
|
||||
isVisible ? "toast-visible" : ""
|
||||
} ${isRemoving ? "toast-removing" : ""}`}
|
||||
onClick={toast.closable !== false ? handleRemove : undefined}
|
||||
>
|
||||
<div className="toast-content">
|
||||
{toast.title && <div className="toast-title">{toast.title}</div>}
|
||||
<div className="toast-message">{toast.message}</div>
|
||||
</div>
|
||||
{toast.closable !== false && (
|
||||
<button className="toast-close" onClick={handleRemove}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast 容器组件
|
||||
const ToastContainer = () => {
|
||||
const { toasts, removeToast } = useContext(ToastContext);
|
||||
|
||||
return (
|
||||
<div className="toast-container">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast Provider 组件
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = (toast) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const newToast = {
|
||||
id,
|
||||
type: TOAST_TYPES.INFO,
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...toast,
|
||||
};
|
||||
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
return id;
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
const removeAllToasts = () => {
|
||||
setToasts([]);
|
||||
};
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
removeAllToasts,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for using toast
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// 全局 Toast 管理器
|
||||
class ToastManager {
|
||||
constructor() {
|
||||
this.container = null;
|
||||
this.root = null;
|
||||
this.toasts = [];
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
// 初始化容器
|
||||
init() {
|
||||
if (this.container) return;
|
||||
|
||||
this.container = document.createElement("div");
|
||||
this.container.className = "global-toast-container";
|
||||
document.body.appendChild(this.container);
|
||||
|
||||
this.root = createRoot(this.container);
|
||||
this.render();
|
||||
}
|
||||
|
||||
// 渲染 Toast 列表
|
||||
render() {
|
||||
if (!this.root) return;
|
||||
|
||||
this.root.render(
|
||||
<div className="toast-container">
|
||||
{this.toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onRemove={this.remove.bind(this)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加 Toast
|
||||
add(options) {
|
||||
this.init();
|
||||
|
||||
const id = Date.now() + Math.random();
|
||||
const toast = {
|
||||
id,
|
||||
type: TOAST_TYPES.INFO,
|
||||
duration: 3000,
|
||||
closable: true,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.toasts.push(toast);
|
||||
this.render();
|
||||
|
||||
// 自动移除
|
||||
if (toast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, toast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// 移除 Toast
|
||||
remove(id) {
|
||||
this.toasts = this.toasts.filter((toast) => toast.id !== id);
|
||||
this.render();
|
||||
|
||||
// 如果没有 Toast 了,清理容器
|
||||
if (this.toasts.length === 0) {
|
||||
setTimeout(() => {
|
||||
this.cleanup();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 移除所有 Toast
|
||||
removeAll() {
|
||||
this.toasts = [];
|
||||
this.render();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
// 清理容器
|
||||
cleanup() {
|
||||
if (this.container && this.toasts.length === 0) {
|
||||
this.root?.unmount();
|
||||
document.body.removeChild(this.container);
|
||||
this.container = null;
|
||||
this.root = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
success(message, options = {}) {
|
||||
return this.add({ ...options, message, type: TOAST_TYPES.SUCCESS });
|
||||
}
|
||||
|
||||
error(message, options = {}) {
|
||||
return this.add({ ...options, message, type: TOAST_TYPES.ERROR });
|
||||
}
|
||||
|
||||
warning(message, options = {}) {
|
||||
return this.add({ ...options, message, type: TOAST_TYPES.WARNING });
|
||||
}
|
||||
|
||||
info(message, options = {}) {
|
||||
return this.add({ ...options, message, type: TOAST_TYPES.INFO });
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
const toastManager = new ToastManager();
|
||||
|
||||
// 导出全局 Toast API
|
||||
export const toast = {
|
||||
success: (message, options) => toastManager.success(message, options),
|
||||
error: (message, options) => toastManager.error(message, options),
|
||||
warning: (message, options) => toastManager.warning(message, options),
|
||||
info: (message, options) => toastManager.info(message, options),
|
||||
remove: (id) => toastManager.remove(id),
|
||||
removeAll: () => toastManager.removeAll(),
|
||||
};
|
||||
|
||||
export default toast;
|
||||
Reference in New Issue
Block a user