完整的教务系统前端项目 - 包含所有修复和9月份数据

This commit is contained in:
KQL
2025-09-03 13:26:13 +08:00
commit 87b06d3176
270 changed files with 116169 additions and 0 deletions

View 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;
}
}
}
}

View 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;

View 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;
}
}
}
}
}
}

View 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;

View 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;
}
}
}
}
}
}

View 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)}>&lt; 上一集</span>
<span className="courses-video-player-header-title">
{courseName}
</span>
<span onClick={() => handleClickBtn(2)}>下一集 &gt;</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>
);
};

View 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;

View 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;
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}
}

View 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>
);
};

View 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;
}
}

View 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;

View 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;

View 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;
}
}
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}
}
}

View 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;

View 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. 支持暗色主题自动切换

View 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;
}
}

View 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;