视图修改、接口修改
@@ -37,7 +37,8 @@ CREATE TABLE `tb_resource` (
|
||||
-- Banner管理表
|
||||
DROP TABLE IF EXISTS `tb_banner`;
|
||||
CREATE TABLE `tb_banner` (
|
||||
`id` VARCHAR(50) NOT NULL COMMENT 'Banner ID',
|
||||
`id` VARCHAR(50) NOT NULL COMMENT 'ID',
|
||||
`banner_id` VARCHAR(50) NOT NULL COMMENT 'Banner ID',
|
||||
`title` VARCHAR(255) NOT NULL COMMENT 'Banner标题',
|
||||
`image_url` VARCHAR(500) NOT NULL COMMENT 'Banner图片URL',
|
||||
`link_type` INT(4) DEFAULT 1 COMMENT '链接类型(1资源 2课程 3外部链接)',
|
||||
@@ -52,6 +53,7 @@ CREATE TABLE `tb_banner` (
|
||||
`delete_time` TIMESTAMP NULL DEFAULT NULL COMMENT '删除时间',
|
||||
`deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_banner_id` (`banner_id`),
|
||||
KEY `idx_order` (`order_num`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='Banner表';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.xyzh.api.news.banner;
|
||||
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.dto.resource.TbBanner;
|
||||
|
||||
import java.util.List;
|
||||
@@ -21,7 +22,7 @@ public interface BannerService {
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<TbBanner> getBannerList(Integer status);
|
||||
ResultDomain<TbBanner> getBannerList(TbBanner filter);
|
||||
|
||||
/**
|
||||
* @description 根据ID获取Banner详情
|
||||
@@ -30,7 +31,7 @@ public interface BannerService {
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<TbBanner> getBannerById(String bannerID);
|
||||
ResultDomain<TbBanner> getBannerPage(PageParam pageParam,TbBanner filter);
|
||||
|
||||
/**
|
||||
* @description 创建Banner
|
||||
@@ -96,4 +97,12 @@ public interface BannerService {
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<Boolean> batchUpdateBannerOrder(java.util.Map<String, Integer> bannerOrders);
|
||||
|
||||
/**
|
||||
* @description 获取首页横幅列表
|
||||
* @return ResultDomain<TbBanner> 首页横幅列表
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<TbBanner> selectHomeBanners();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
package org.xyzh.api.study.course;
|
||||
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.dto.study.TbCourseChapter;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.dto.study.TbLearningRecord;
|
||||
import org.xyzh.common.vo.CourseVO;
|
||||
|
||||
import java.util.List;
|
||||
import org.xyzh.common.vo.CourseItemVO;
|
||||
|
||||
/**
|
||||
* @description 课程服务接口
|
||||
@@ -41,29 +37,29 @@ public interface CourseService {
|
||||
/**
|
||||
* @description 根据ID获取课程详情
|
||||
* @param courseID 课程ID
|
||||
* @return ResultDomain<TbCourse> 课程详情
|
||||
* @return ResultDomain<CourseItemVO> 课程详情
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<CourseVO> getCourseById(String courseID);
|
||||
ResultDomain<CourseItemVO> getCourseById(String courseID);
|
||||
|
||||
/**
|
||||
* @description 创建课程
|
||||
* @param course 课程信息
|
||||
* @return ResultDomain<TbCourse> 创建结果
|
||||
* @param courseItemVO 课程信息
|
||||
* @return ResultDomain<CourseItemVO> 创建结果
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<CourseVO> createCourse(CourseVO courseVO);
|
||||
ResultDomain<CourseItemVO> createCourse(CourseItemVO courseItemVO);
|
||||
|
||||
/**
|
||||
* @description 更新课程
|
||||
* @param courseVO 课程信息
|
||||
* @return ResultDomain<CourseVO> 更新结果
|
||||
* @param courseItemVO 课程信息
|
||||
* @return ResultDomain<CourseItemVO> 更新结果
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<CourseVO> updateCourse(CourseVO courseVO);
|
||||
ResultDomain<CourseItemVO> updateCourse(CourseItemVO courseItemVO);
|
||||
|
||||
/**
|
||||
* @description 删除课程
|
||||
@@ -185,4 +181,13 @@ public interface CourseService {
|
||||
* @since 2025-10-22
|
||||
*/
|
||||
ResultDomain<Boolean> deleteChapterNode(String nodeID);
|
||||
|
||||
/**
|
||||
* @description 获取课程进度
|
||||
* @param courseID 课程ID
|
||||
* @return ResultDomain<CourseItemVO> 课程进度
|
||||
* @author yslg
|
||||
* @since 2025-10-28
|
||||
*/
|
||||
ResultDomain<CourseItemVO> getCourseProgress(String courseID);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import org.xyzh.common.dto.BaseDTO;
|
||||
public class TbBanner extends BaseDTO {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String bannerID;
|
||||
|
||||
/**
|
||||
* @description Banner标题
|
||||
@@ -58,6 +60,14 @@ public class TbBanner extends BaseDTO {
|
||||
*/
|
||||
private String updater;
|
||||
|
||||
public String getBannerID() {
|
||||
return bannerID;
|
||||
}
|
||||
|
||||
public void setBannerID(String bannerID) {
|
||||
this.bannerID = bannerID;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
@@ -134,6 +144,7 @@ public class TbBanner extends BaseDTO {
|
||||
public String toString() {
|
||||
return "TbBanner{" +
|
||||
"id=" + getID() +
|
||||
", bannerID='" + bannerID + '\'' +
|
||||
", title='" + title + '\'' +
|
||||
", imageUrl='" + imageUrl + '\'' +
|
||||
", linkType=" + linkType +
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package org.xyzh.common.vo;
|
||||
|
||||
import org.xyzh.common.dto.BaseDTO;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.dto.study.TbCourseChapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ChapterVO extends BaseDTO{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private TbCourseChapter chapter;
|
||||
private List<TbCourseNode> nodes;
|
||||
|
||||
public TbCourseChapter getChapter() {
|
||||
return chapter;
|
||||
}
|
||||
public void setChapter(TbCourseChapter chapter) {
|
||||
this.chapter = chapter;
|
||||
}
|
||||
public List<TbCourseNode> getNodes() {
|
||||
return nodes;
|
||||
}
|
||||
public void setNodes(List<TbCourseNode> nodes) {
|
||||
this.nodes = nodes;
|
||||
}
|
||||
|
||||
public void addNode(TbCourseNode node) {
|
||||
if (nodes == null) {
|
||||
nodes = new ArrayList<>();
|
||||
}
|
||||
nodes.add(node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package org.xyzh.common.vo;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.xyzh.common.dto.BaseDTO;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.dto.study.TbCourseChapter;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.dto.study.TbLearningRecord;
|
||||
|
||||
/**
|
||||
* @description 课程项目VO - 包含课程和学习记录的关键信息
|
||||
* @filename CourseItemVO.java
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-10-28
|
||||
*/
|
||||
public class CourseItemVO extends BaseDTO {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
// ========== 课程基本信息 ==========
|
||||
/**
|
||||
* @description 课程ID
|
||||
*/
|
||||
private String courseID;
|
||||
|
||||
/**
|
||||
* @description 课程名称
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* @description 课程封面图片
|
||||
*/
|
||||
private String coverImage;
|
||||
|
||||
/**
|
||||
* @description 课程描述
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* @description 课程时长(分钟)
|
||||
*/
|
||||
private Integer duration;
|
||||
|
||||
/**
|
||||
* @description 授课老师
|
||||
*/
|
||||
private String teacher;
|
||||
|
||||
/**
|
||||
* @description 课程状态(0未上线 1已上线 2已下架)
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* @description 浏览次数
|
||||
*/
|
||||
private Integer viewCount;
|
||||
|
||||
/**
|
||||
* @description 学习人数
|
||||
*/
|
||||
private Integer learnCount;
|
||||
|
||||
/**
|
||||
* @description 课程创建时间
|
||||
*/
|
||||
private Date createTime;
|
||||
|
||||
// ========== 学习记录信息 ==========
|
||||
/**
|
||||
* @description 学习记录ID
|
||||
*/
|
||||
private String recordID;
|
||||
|
||||
/**
|
||||
* @description 学习进度(0-100)
|
||||
*/
|
||||
private BigDecimal progress;
|
||||
|
||||
/**
|
||||
* @description 是否完成
|
||||
*/
|
||||
private Boolean isComplete;
|
||||
|
||||
/**
|
||||
* @description 学习时长(秒)
|
||||
*/
|
||||
private Integer learningDuration;
|
||||
|
||||
/**
|
||||
* @description 最后学习时间
|
||||
*/
|
||||
private Date lastLearnTime;
|
||||
|
||||
/**
|
||||
* @description 完成时间
|
||||
*/
|
||||
private Date completeTime;
|
||||
|
||||
// ========== 章节节点信息 ==========
|
||||
/**
|
||||
* @description 章节ID(当此对象代表章节或节点时使用)
|
||||
*/
|
||||
private String chapterID;
|
||||
|
||||
/**
|
||||
* @description 节点ID(当此对象代表节点时使用)
|
||||
*/
|
||||
private String nodeID;
|
||||
|
||||
/**
|
||||
* @description 父级ID(章节的父章节ID)
|
||||
*/
|
||||
private String parentID;
|
||||
|
||||
/**
|
||||
* @description 节点类型(1视频 2文档 3音频 4图片 5链接)
|
||||
*/
|
||||
private Integer nodeType;
|
||||
|
||||
/**
|
||||
* @description 节点内容(富文本内容)
|
||||
*/
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* @description 视频URL
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* @description 资源ID
|
||||
*/
|
||||
private String resourceID;
|
||||
|
||||
/**
|
||||
* @description 排序号
|
||||
*/
|
||||
private Integer orderNum;
|
||||
|
||||
/**
|
||||
* @description 是否必修(1必修 0选修)
|
||||
*/
|
||||
private Integer isRequired;
|
||||
|
||||
// ========== 层级结构 ==========
|
||||
/**
|
||||
* @description 章节列表(课程的章节列表)
|
||||
*/
|
||||
private List<CourseItemVO> chapters;
|
||||
|
||||
/**
|
||||
* @description 章节节点映射(key: chapterID, value: 该章节下的节点列表)
|
||||
*/
|
||||
private Map<String, List<CourseItemVO>> chapterNodes;
|
||||
|
||||
// ========== Getters and Setters ==========
|
||||
|
||||
public String getCourseID() {
|
||||
return courseID;
|
||||
}
|
||||
|
||||
public void setCourseID(String courseID) {
|
||||
this.courseID = courseID;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getCoverImage() {
|
||||
return coverImage;
|
||||
}
|
||||
|
||||
public void setCoverImage(String coverImage) {
|
||||
this.coverImage = coverImage;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public Integer getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
public void setDuration(Integer duration) {
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
public String getTeacher() {
|
||||
return teacher;
|
||||
}
|
||||
|
||||
public void setTeacher(String teacher) {
|
||||
this.teacher = teacher;
|
||||
}
|
||||
|
||||
public Integer getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(Integer status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Integer getViewCount() {
|
||||
return viewCount;
|
||||
}
|
||||
|
||||
public void setViewCount(Integer viewCount) {
|
||||
this.viewCount = viewCount;
|
||||
}
|
||||
|
||||
public Integer getLearnCount() {
|
||||
return learnCount;
|
||||
}
|
||||
|
||||
public void setLearnCount(Integer learnCount) {
|
||||
this.learnCount = learnCount;
|
||||
}
|
||||
|
||||
public Date getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public void setCreateTime(Date createTime) {
|
||||
this.createTime = createTime;
|
||||
}
|
||||
|
||||
public String getRecordID() {
|
||||
return recordID;
|
||||
}
|
||||
|
||||
public void setRecordID(String recordID) {
|
||||
this.recordID = recordID;
|
||||
}
|
||||
|
||||
public BigDecimal getProgress() {
|
||||
return progress;
|
||||
}
|
||||
|
||||
public void setProgress(BigDecimal progress) {
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public Boolean getIsComplete() {
|
||||
return isComplete;
|
||||
}
|
||||
|
||||
public void setIsComplete(Boolean isComplete) {
|
||||
this.isComplete = isComplete;
|
||||
}
|
||||
|
||||
public Integer getLearningDuration() {
|
||||
return learningDuration;
|
||||
}
|
||||
|
||||
public void setLearningDuration(Integer learningDuration) {
|
||||
this.learningDuration = learningDuration;
|
||||
}
|
||||
|
||||
public Date getLastLearnTime() {
|
||||
return lastLearnTime;
|
||||
}
|
||||
|
||||
public void setLastLearnTime(Date lastLearnTime) {
|
||||
this.lastLearnTime = lastLearnTime;
|
||||
}
|
||||
|
||||
public Date getCompleteTime() {
|
||||
return completeTime;
|
||||
}
|
||||
|
||||
public void setCompleteTime(Date completeTime) {
|
||||
this.completeTime = completeTime;
|
||||
}
|
||||
|
||||
public String getChapterID() {
|
||||
return chapterID;
|
||||
}
|
||||
|
||||
public void setChapterID(String chapterID) {
|
||||
this.chapterID = chapterID;
|
||||
}
|
||||
|
||||
public String getNodeID() {
|
||||
return nodeID;
|
||||
}
|
||||
|
||||
public void setNodeID(String nodeID) {
|
||||
this.nodeID = nodeID;
|
||||
}
|
||||
|
||||
public String getParentID() {
|
||||
return parentID;
|
||||
}
|
||||
|
||||
public void setParentID(String parentID) {
|
||||
this.parentID = parentID;
|
||||
}
|
||||
|
||||
public Integer getNodeType() {
|
||||
return nodeType;
|
||||
}
|
||||
|
||||
public void setNodeType(Integer nodeType) {
|
||||
this.nodeType = nodeType;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public String getVideoUrl() {
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
public void setVideoUrl(String videoUrl) {
|
||||
this.videoUrl = videoUrl;
|
||||
}
|
||||
|
||||
public String getResourceID() {
|
||||
return resourceID;
|
||||
}
|
||||
|
||||
public void setResourceID(String resourceID) {
|
||||
this.resourceID = resourceID;
|
||||
}
|
||||
|
||||
public Integer getOrderNum() {
|
||||
return orderNum;
|
||||
}
|
||||
|
||||
public void setOrderNum(Integer orderNum) {
|
||||
this.orderNum = orderNum;
|
||||
}
|
||||
|
||||
public Integer getIsRequired() {
|
||||
return isRequired;
|
||||
}
|
||||
|
||||
public void setIsRequired(Integer isRequired) {
|
||||
this.isRequired = isRequired;
|
||||
}
|
||||
|
||||
public List<CourseItemVO> getChapters() {
|
||||
return chapters;
|
||||
}
|
||||
|
||||
public void setChapters(List<CourseItemVO> chapters) {
|
||||
this.chapters = chapters;
|
||||
}
|
||||
|
||||
public Map<String, List<CourseItemVO>> getChapterNodes() {
|
||||
return chapterNodes;
|
||||
}
|
||||
|
||||
public void setChapterNodes(Map<String, List<CourseItemVO>> chapterNodes) {
|
||||
this.chapterNodes = chapterNodes;
|
||||
}
|
||||
|
||||
// ========== 类型转换方法 ==========
|
||||
|
||||
/**
|
||||
* @description 转换为课程实体对象
|
||||
* @return TbCourse 课程实体
|
||||
*/
|
||||
public TbCourse toCourse() {
|
||||
TbCourse course = new TbCourse();
|
||||
course.setID(this.getID());
|
||||
course.setCourseID(this.courseID);
|
||||
course.setName(this.name);
|
||||
course.setCoverImage(this.coverImage);
|
||||
course.setDescription(this.description);
|
||||
course.setDuration(this.duration);
|
||||
course.setTeacher(this.teacher);
|
||||
course.setStatus(this.status);
|
||||
course.setViewCount(this.viewCount);
|
||||
course.setLearnCount(this.learnCount);
|
||||
course.setCreateTime(this.createTime);
|
||||
return course;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从课程实体创建CourseItemVO
|
||||
* @param course 课程实体
|
||||
* @return CourseItemVO
|
||||
*/
|
||||
public static CourseItemVO fromCourse(TbCourse course) {
|
||||
if (course == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CourseItemVO vo = new CourseItemVO();
|
||||
vo.setID(course.getID());
|
||||
vo.setCourseID(course.getCourseID());
|
||||
vo.setName(course.getName());
|
||||
vo.setCoverImage(course.getCoverImage());
|
||||
vo.setDescription(course.getDescription());
|
||||
vo.setDuration(course.getDuration());
|
||||
vo.setTeacher(course.getTeacher());
|
||||
vo.setStatus(course.getStatus());
|
||||
vo.setViewCount(course.getViewCount());
|
||||
vo.setLearnCount(course.getLearnCount());
|
||||
vo.setCreateTime(course.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 转换为学习记录实体对象
|
||||
* @return TbLearningRecord 学习记录实体
|
||||
*/
|
||||
public TbLearningRecord toLearningRecord() {
|
||||
TbLearningRecord record = new TbLearningRecord();
|
||||
if (this.recordID != null) {
|
||||
// 如果有recordID,说明是从已有记录转换的,需要设置ID
|
||||
record.setID(this.recordID);
|
||||
}
|
||||
record.setCourseID(this.courseID);
|
||||
record.setDuration(this.learningDuration);
|
||||
record.setProgress(this.progress);
|
||||
record.setIsComplete(this.isComplete);
|
||||
record.setCompleteTime(this.completeTime);
|
||||
record.setLastLearnTime(this.lastLearnTime);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 设置学习记录信息
|
||||
* @param record 学习记录实体
|
||||
*/
|
||||
public void setLearningRecordInfo(TbLearningRecord record) {
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordID = record.getID();
|
||||
this.progress = record.getProgress();
|
||||
this.isComplete = record.getIsComplete();
|
||||
this.learningDuration = record.getDuration();
|
||||
this.lastLearnTime = record.getLastLearnTime();
|
||||
this.completeTime = record.getCompleteTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从课程和学习记录创建CourseItemVO
|
||||
* @param course 课程实体
|
||||
* @param record 学习记录实体
|
||||
* @return CourseItemVO
|
||||
*/
|
||||
public static CourseItemVO fromCourseAndRecord(TbCourse course, TbLearningRecord record) {
|
||||
CourseItemVO vo = fromCourse(course);
|
||||
if (vo != null && record != null) {
|
||||
vo.setLearningRecordInfo(record);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从章节创建CourseItemVO
|
||||
* @param chapter 章节实体
|
||||
* @return CourseItemVO
|
||||
*/
|
||||
public static CourseItemVO fromChapter(TbCourseChapter chapter) {
|
||||
if (chapter == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CourseItemVO vo = new CourseItemVO();
|
||||
vo.setID(chapter.getID());
|
||||
vo.setChapterID(chapter.getChapterID());
|
||||
vo.setCourseID(chapter.getCourseID());
|
||||
vo.setParentID(chapter.getParentID());
|
||||
vo.setName(chapter.getName());
|
||||
vo.setDescription(chapter.getContent());
|
||||
vo.setDuration(chapter.getDuration());
|
||||
vo.setVideoUrl(chapter.getVideoUrl());
|
||||
vo.setResourceID(chapter.getResourceID());
|
||||
vo.setNodeType(chapter.getChapterType());
|
||||
vo.setOrderNum(chapter.getOrderNum());
|
||||
vo.setCreateTime(chapter.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从节点创建CourseItemVO
|
||||
* @param node 节点实体
|
||||
* @return CourseItemVO
|
||||
*/
|
||||
public static CourseItemVO fromNode(TbCourseNode node) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CourseItemVO vo = new CourseItemVO();
|
||||
vo.setID(node.getID());
|
||||
vo.setNodeID(node.getNodeID());
|
||||
vo.setChapterID(node.getChapterID());
|
||||
vo.setName(node.getName());
|
||||
|
||||
vo.setContent(node.getContent());
|
||||
vo.setDuration(node.getDuration());
|
||||
vo.setVideoUrl(node.getVideoUrl());
|
||||
vo.setResourceID(node.getResourceID());
|
||||
vo.setNodeType(node.getNodeType());
|
||||
vo.setOrderNum(node.getOrderNum());
|
||||
vo.setIsRequired(node.getIsRequired());
|
||||
vo.setCreateTime(node.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 转换为章节实体对象
|
||||
* @return TbCourseChapter 章节实体
|
||||
*/
|
||||
public TbCourseChapter toChapter() {
|
||||
TbCourseChapter chapter = new TbCourseChapter();
|
||||
chapter.setID(this.getID());
|
||||
chapter.setChapterID(this.chapterID);
|
||||
chapter.setCourseID(this.courseID);
|
||||
chapter.setParentID(this.parentID);
|
||||
chapter.setName(this.name);
|
||||
chapter.setContent(this.description);
|
||||
chapter.setDuration(this.duration);
|
||||
chapter.setVideoUrl(this.videoUrl);
|
||||
chapter.setResourceID(this.resourceID);
|
||||
chapter.setChapterType(this.nodeType);
|
||||
chapter.setOrderNum(this.orderNum);
|
||||
return chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 转换为节点实体对象
|
||||
* @return TbCourseNode 节点实体
|
||||
*/
|
||||
public TbCourseNode toNode() {
|
||||
TbCourseNode node = new TbCourseNode();
|
||||
node.setID(this.getID());
|
||||
node.setNodeID(this.nodeID);
|
||||
node.setChapterID(this.chapterID);
|
||||
node.setName(this.name);
|
||||
node.setContent(this.content);
|
||||
node.setDuration(this.duration);
|
||||
node.setVideoUrl(this.videoUrl);
|
||||
node.setResourceID(this.resourceID);
|
||||
node.setNodeType(this.nodeType);
|
||||
node.setOrderNum(this.orderNum);
|
||||
node.setIsRequired(this.isRequired);
|
||||
return node;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "CourseItemVO{" +
|
||||
"courseID='" + courseID + '\'' +
|
||||
", name='" + name + '\'' +
|
||||
", coverImage='" + coverImage + '\'' +
|
||||
", teacher='" + teacher + '\'' +
|
||||
", duration=" + duration +
|
||||
", status=" + status +
|
||||
", progress=" + progress +
|
||||
", isComplete=" + isComplete +
|
||||
", learningDuration=" + learningDuration +
|
||||
", lastLearnTime=" + lastLearnTime +
|
||||
", viewCount=" + viewCount +
|
||||
", learnCount=" + learnCount +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package org.xyzh.common.vo;
|
||||
|
||||
import org.xyzh.common.dto.BaseDTO;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.dto.study.TbCourseTag;
|
||||
import org.xyzh.common.vo.ChapterVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class CourseVO extends BaseDTO{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private TbCourse course;
|
||||
private List<ChapterVO> courseChapters;
|
||||
private List<TbCourseTag> courseTags;
|
||||
|
||||
|
||||
public TbCourse getCourse() {
|
||||
return course;
|
||||
}
|
||||
public void setCourse(TbCourse course) {
|
||||
this.course = course;
|
||||
}
|
||||
public List<ChapterVO> getCourseChapters() {
|
||||
return courseChapters;
|
||||
}
|
||||
public void setCourseChapters(List<ChapterVO> courseChapters) {
|
||||
this.courseChapters = courseChapters;
|
||||
}
|
||||
public List<TbCourseTag> getCourseTags() {
|
||||
return courseTags;
|
||||
}
|
||||
public void setCourseTags(List<TbCourseTag> courseTags) {
|
||||
this.courseTags = courseTags;
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.xyzh.crontab.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.xyzh.crontab.task.DataBackupTask;
|
||||
import org.xyzh.crontab.task.LogCleanTask;
|
||||
import org.xyzh.crontab.task.SystemStatisticsTask;
|
||||
|
||||
public enum TaskEnums {
|
||||
DATA_BACKUP("dataBackup", DataBackupTask.class),
|
||||
LOG_CLEAN("logClean", LogCleanTask.class),
|
||||
SystemStatistics("systemStatistics", SystemStatisticsTask.class);
|
||||
|
||||
|
||||
private String name;
|
||||
private Class<?> clazz;
|
||||
|
||||
TaskEnums(String name, Class<?> clazz) {
|
||||
this.name = name;
|
||||
this.clazz = clazz;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Class<?> getClazz() {
|
||||
return clazz;
|
||||
}
|
||||
|
||||
public static TaskEnums getByName(String name) {
|
||||
return Arrays.stream(TaskEnums.values())
|
||||
.filter(task -> task.getName().equals(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.xyzh.crontab.task.newsTask;
|
||||
|
||||
|
||||
abstract public class NewsTask {
|
||||
|
||||
// 爬取网站目标
|
||||
private String target;
|
||||
// 爬取标题
|
||||
private String title;
|
||||
|
||||
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.xyzh.api.news.banner.BannerService;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.dto.resource.TbBanner;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
|
||||
/**
|
||||
* @description 横幅控制器
|
||||
@@ -27,16 +31,16 @@ public class BannerController {
|
||||
* 获取横幅列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
public ResultDomain<TbBanner> getBannerList() {
|
||||
return bannerService.getBannerList(null);
|
||||
public ResultDomain<TbBanner> getBannerList(TbBanner filter) {
|
||||
return bannerService.getBannerList(filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取横幅详情
|
||||
*/
|
||||
@GetMapping("/banner/{bannerID}")
|
||||
public ResultDomain<TbBanner> getBannerById(@PathVariable String bannerID) {
|
||||
return bannerService.getBannerById(bannerID);
|
||||
@PostMapping("/banner/page")
|
||||
public ResultDomain<TbBanner> getBannerPage(@RequestBody PageRequest<TbBanner> pageRequest) {
|
||||
return bannerService.getBannerPage(pageRequest.getPageParam(), pageRequest.getFilter());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,36 +62,27 @@ public class BannerController {
|
||||
/**
|
||||
* 删除横幅
|
||||
*/
|
||||
@DeleteMapping("/banner/{bannerID}")
|
||||
public ResultDomain<Boolean> deleteBanner(@PathVariable String bannerID) {
|
||||
return bannerService.deleteBanner(bannerID);
|
||||
@DeleteMapping("/banner")
|
||||
public ResultDomain<Boolean> deleteBanner(@RequestBody TbBanner banner) {
|
||||
return bannerService.deleteBanner(banner.getBannerID());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新横幅状态
|
||||
*/
|
||||
@PutMapping("/banner/{bannerID}/status")
|
||||
public ResultDomain<TbBanner> updateBannerStatus(
|
||||
@PathVariable String bannerID,
|
||||
@RequestParam Integer status) {
|
||||
return bannerService.updateBannerStatus(bannerID, status);
|
||||
@PutMapping("/banner/status")
|
||||
public ResultDomain<TbBanner> updateBannerStatus(@RequestBody TbBanner banner) {
|
||||
return bannerService.updateBannerStatus(banner.getBannerID(), banner.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新横幅排序
|
||||
* 获取首页横幅列表
|
||||
*/
|
||||
@PutMapping("/banner/{bannerID}/order")
|
||||
public ResultDomain<TbBanner> updateBannerOrder(
|
||||
@PathVariable String bannerID,
|
||||
@RequestParam Integer orderNum) {
|
||||
return bannerService.updateBannerOrder(bannerID, orderNum);
|
||||
@GetMapping("/home")
|
||||
public ResultDomain<TbBanner> getHomeBannerList() {
|
||||
return bannerService.selectHomeBanners();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取活跃横幅
|
||||
*/
|
||||
@GetMapping("/active")
|
||||
public ResultDomain<TbBanner> getActiveBanners(@RequestParam(required = false) Integer limit) {
|
||||
return bannerService.getActiveBanners(limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,19 @@ public interface BannerMapper extends BaseMapper<TbBanner> {
|
||||
*/
|
||||
List<TbBanner> selectBanners(TbBanner filter);
|
||||
|
||||
List<TbBanner> selectBannersLimit(@Param("filter") TbBanner filter, @Param("limit") Integer limit);
|
||||
|
||||
|
||||
/**
|
||||
* @description 分页查询Banner
|
||||
* @param filter 过滤条件
|
||||
* @param pageParam 分页参数
|
||||
* @return List<TbBanner> Banner列表
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
List<TbBanner> selectBannersPage(@Param("filter") TbBanner filter, @Param("pageParam") PageParam pageParam);
|
||||
|
||||
/**
|
||||
* @description 根据Banner ID查询Banner信息
|
||||
* @param bannerId Banner ID
|
||||
@@ -117,16 +130,6 @@ public interface BannerMapper extends BaseMapper<TbBanner> {
|
||||
*/
|
||||
int batchDeleteBanners(@Param("ids") List<String> ids);
|
||||
|
||||
/**
|
||||
* @description 分页查询Banner
|
||||
* @param filter 过滤条件
|
||||
* @param pageParam 分页参数
|
||||
* @return List<TbBanner> Banner列表
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
List<TbBanner> selectBannersPage(@Param("filter") TbBanner filter, @Param("pageParam") PageParam pageParam);
|
||||
|
||||
/**
|
||||
* @description 统计Banner总数
|
||||
* @param filter 过滤条件
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.core.page.PageDomain;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.dto.resource.TbBanner;
|
||||
import org.xyzh.common.utils.IDUtils;
|
||||
import org.xyzh.news.mapper.BannerMapper;
|
||||
@@ -32,49 +34,27 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
private BannerMapper bannerMapper;
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbBanner> getBannerList(Integer status) {
|
||||
public ResultDomain<TbBanner> getBannerList(TbBanner filter) {
|
||||
ResultDomain<TbBanner> resultDomain = new ResultDomain<>();
|
||||
try {
|
||||
List<TbBanner> list;
|
||||
if (status != null) {
|
||||
list = bannerMapper.selectByStatus(status);
|
||||
} else {
|
||||
TbBanner filter = new TbBanner();
|
||||
list = bannerMapper.selectBanners(filter);
|
||||
}
|
||||
resultDomain.success("获取横幅列表成功", list);
|
||||
return resultDomain;
|
||||
} catch (Exception e) {
|
||||
logger.error("获取横幅列表异常: {}", e.getMessage(), e);
|
||||
resultDomain.fail("获取横幅列表失败: " + e.getMessage());
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
List<TbBanner> list = bannerMapper.selectBanners(filter);
|
||||
resultDomain.success("获取横幅列表成功", list);
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbBanner> getBannerById(String bannerID) {
|
||||
public ResultDomain<TbBanner> getBannerPage(PageParam pageParam,TbBanner filter) {
|
||||
ResultDomain<TbBanner> resultDomain = new ResultDomain<>();
|
||||
try {
|
||||
// 参数验证
|
||||
if (!StringUtils.hasText(bannerID)) {
|
||||
resultDomain.fail("横幅ID不能为空");
|
||||
return resultDomain;
|
||||
}
|
||||
List<TbBanner> list = bannerMapper.selectBannersPage(filter, pageParam);
|
||||
PageDomain<TbBanner> pageDomain = new PageDomain<>();
|
||||
int total = (int)bannerMapper.countBanners(filter);
|
||||
pageParam.setTotalElements(total);
|
||||
pageParam.setTotalPages( (int)Math.ceil((double)total / pageParam.getPageSize()));
|
||||
pageDomain.setDataList(list);
|
||||
pageDomain.setPageParam(pageParam);
|
||||
|
||||
// 查询横幅
|
||||
TbBanner banner = bannerMapper.selectByBannerId(bannerID);
|
||||
if (banner == null || banner.getDeleted()) {
|
||||
resultDomain.fail("横幅不存在");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
resultDomain.success("获取横幅详情成功", banner);
|
||||
return resultDomain;
|
||||
} catch (Exception e) {
|
||||
logger.error("获取横幅详情异常: {}", e.getMessage(), e);
|
||||
resultDomain.fail("获取横幅详情失败: " + e.getMessage());
|
||||
return resultDomain;
|
||||
}
|
||||
resultDomain.success("获取横幅列表成功", pageDomain);
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -103,7 +83,7 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
if (banner.getID() == null) {
|
||||
banner.setID(IDUtils.generateID());
|
||||
}
|
||||
|
||||
banner.setBannerID(IDUtils.generateID());
|
||||
banner.setCreateTime(new Date());
|
||||
banner.setUpdateTime(new Date());
|
||||
banner.setDeleted(false);
|
||||
@@ -139,13 +119,13 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
ResultDomain<TbBanner> resultDomain = new ResultDomain<>();
|
||||
try {
|
||||
// 参数验证
|
||||
if (banner == null || !StringUtils.hasText(banner.getID())) {
|
||||
if (banner == null || !StringUtils.hasText(banner.getBannerID())) {
|
||||
resultDomain.fail("横幅ID不能为空");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 检查横幅是否存在
|
||||
TbBanner existing = bannerMapper.selectById(banner.getID());
|
||||
TbBanner existing = bannerMapper.selectByBannerId(banner.getBannerID());
|
||||
if (existing == null || existing.getDeleted()) {
|
||||
resultDomain.fail("横幅不存在");
|
||||
return resultDomain;
|
||||
@@ -153,7 +133,7 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
|
||||
// 如果修改了标题,检查标题是否已被使用
|
||||
if (StringUtils.hasText(banner.getTitle()) && !banner.getTitle().equals(existing.getTitle())) {
|
||||
int count = bannerMapper.countByTitle(banner.getTitle(), banner.getID());
|
||||
int count = bannerMapper.countByTitle(banner.getTitle(), banner.getBannerID());
|
||||
if (count > 0) {
|
||||
resultDomain.fail("横幅标题已存在");
|
||||
return resultDomain;
|
||||
@@ -166,9 +146,9 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
// 更新数据库
|
||||
int result = bannerMapper.updateBanner(banner);
|
||||
if (result > 0) {
|
||||
logger.info("更新横幅成功: {}", banner.getID());
|
||||
logger.info("更新横幅成功: {}", banner.getBannerID());
|
||||
// 重新查询返回完整数据
|
||||
TbBanner updated = bannerMapper.selectById(banner.getID());
|
||||
TbBanner updated = bannerMapper.selectByBannerId(banner.getBannerID());
|
||||
resultDomain.success("更新横幅成功", updated);
|
||||
return resultDomain;
|
||||
} else {
|
||||
@@ -247,7 +227,7 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
if (result > 0) {
|
||||
logger.info("更新横幅状态成功: {}", bannerID);
|
||||
// 重新查询返回完整数据
|
||||
TbBanner updated = bannerMapper.selectById(banner.getID());
|
||||
TbBanner updated = bannerMapper.selectByBannerId(banner.getBannerID());
|
||||
resultDomain.success("更新横幅状态成功", updated);
|
||||
return resultDomain;
|
||||
} else {
|
||||
@@ -291,7 +271,7 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
if (result > 0) {
|
||||
logger.info("更新横幅排序成功: {}", bannerID);
|
||||
// 重新查询返回完整数据
|
||||
TbBanner updated = bannerMapper.selectById(banner.getID());
|
||||
TbBanner updated = bannerMapper.selectByBannerId(banner.getBannerID());
|
||||
resultDomain.success("更新横幅排序成功", updated);
|
||||
return resultDomain;
|
||||
} else {
|
||||
@@ -377,4 +357,15 @@ public class NCBannerServiceImpl implements BannerService {
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbBanner> selectHomeBanners() {
|
||||
ResultDomain<TbBanner> resultDomain = new ResultDomain<>();
|
||||
TbBanner filter = new TbBanner();
|
||||
filter.setStatus(1);
|
||||
|
||||
List<TbBanner> list = bannerMapper.selectBannersLimit(filter, 5);
|
||||
resultDomain.success("获取首页横幅列表成功", list);
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import org.springframework.util.StringUtils;
|
||||
import org.xyzh.common.core.domain.ResultDomain;
|
||||
import org.xyzh.common.dto.resource.TbResourceTag;
|
||||
import org.xyzh.common.dto.resource.TbTag;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.common.utils.IDUtils;
|
||||
import org.xyzh.news.mapper.ResourceTagMapper;
|
||||
import org.xyzh.news.mapper.TagMapper;
|
||||
import org.xyzh.system.utils.LoginUtil;
|
||||
import org.xyzh.api.news.tag.TagService;
|
||||
|
||||
/**
|
||||
@@ -89,39 +91,27 @@ public class NCTagServiceImpl implements TagService {
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ResultDomain<TbTag> updateTag(TbTag tag) {
|
||||
ResultDomain<TbTag> resultDomain = new ResultDomain<>();
|
||||
TbSysUser user = LoginUtil.getCurrentUser();
|
||||
if (user == null) {
|
||||
resultDomain.fail("用户未登录");
|
||||
return resultDomain;
|
||||
}
|
||||
try {
|
||||
// 参数验证
|
||||
if (tag == null || !StringUtils.hasText(tag.getID())) {
|
||||
if (tag == null || !StringUtils.hasText(tag.getTagID())) {
|
||||
resultDomain.fail("标签ID不能为空");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 检查标签是否存在
|
||||
TbTag existingTag = tagMapper.selectById(tag.getID());
|
||||
if (existingTag == null || existingTag.getDeleted()) {
|
||||
resultDomain.fail("标签不存在");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 检查标签名称是否重复(排除自身,同类型下)
|
||||
if (StringUtils.hasText(tag.getName())) {
|
||||
Integer tagType = tag.getTagType() != null ? tag.getTagType() : existingTag.getTagType();
|
||||
int count = tagMapper.countByNameAndType(tag.getName(), tagType, tag.getID());
|
||||
if (count > 0) {
|
||||
resultDomain.fail("该类型下标签名称已存在");
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新时间
|
||||
tag.setUpdater(user.getID());
|
||||
tag.setUpdateTime(new Date());
|
||||
|
||||
// 更新数据库
|
||||
int result = tagMapper.updateTag(tag);
|
||||
if (result > 0) {
|
||||
logger.info("更新标签成功: {}", tag.getID());
|
||||
logger.info("更新标签成功: {}", tag.getTagID());
|
||||
// 重新查询返回完整数据
|
||||
TbTag updated = tagMapper.selectById(tag.getID());
|
||||
TbTag updated = tagMapper.selectByTagId(tag.getTagID());
|
||||
resultDomain.success("更新标签成功", updated);
|
||||
return resultDomain;
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<!-- 基础结果映射 -->
|
||||
<resultMap id="BaseResultMap" type="org.xyzh.common.dto.resource.TbBanner">
|
||||
<id column="id" property="id" jdbcType="VARCHAR"/>
|
||||
<result column="banner_id" property="bannerID" jdbcType="VARCHAR"/>
|
||||
<result column="title" property="title" jdbcType="VARCHAR"/>
|
||||
<result column="image_url" property="imageUrl" jdbcType="VARCHAR"/>
|
||||
<result column="link_type" property="linkType" jdbcType="INTEGER"/>
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
<!-- 基础字段 -->
|
||||
<sql id="Base_Column_List">
|
||||
id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
id, banner_id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
creator, updater, create_time, update_time, delete_time, deleted
|
||||
</sql>
|
||||
|
||||
@@ -30,6 +31,9 @@
|
||||
<sql id="Where_Clause">
|
||||
<where>
|
||||
deleted = 0
|
||||
<if test="bannerID != null and bannerID != ''">
|
||||
AND banner_id = #{bannerID}
|
||||
</if>
|
||||
<if test="title != null and title != ''">
|
||||
AND title LIKE CONCAT('%', #{title}, '%')
|
||||
</if>
|
||||
@@ -44,6 +48,26 @@
|
||||
</if>
|
||||
</where>
|
||||
</sql>
|
||||
<sql id="Filter_Clause">
|
||||
<where>
|
||||
deleted = 0
|
||||
<if test="filter.bannerID != null and filter.bannerID != ''">
|
||||
AND banner_id = #{filter.bannerID}
|
||||
</if>
|
||||
<if test="filter.title != null and filter.title != ''">
|
||||
AND title LIKE CONCAT('%', #{filter.title}, '%')
|
||||
</if>
|
||||
<if test="filter.linkType != null">
|
||||
AND link_type = #{filter.linkType}
|
||||
</if>
|
||||
<if test="filter.linkID != null and filter.linkID != ''">
|
||||
AND link_id = #{filter.linkID}
|
||||
</if>
|
||||
<if test="filter.status != null">
|
||||
AND status = #{filter.status}
|
||||
</if>
|
||||
</where>
|
||||
</sql>
|
||||
|
||||
<!-- selectBanners -->
|
||||
<select id="selectBanners" resultMap="BaseResultMap">
|
||||
@@ -54,12 +78,21 @@
|
||||
ORDER BY order_num ASC, create_time DESC
|
||||
</select>
|
||||
|
||||
<select id="selectBannersLimit" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List"/>
|
||||
FROM tb_banner
|
||||
<include refid="Filter_Clause"/>
|
||||
ORDER BY order_num ASC, create_time DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- 根据Banner ID查询Banner信息 -->
|
||||
<select id="selectByBannerId" resultMap="BaseResultMap">
|
||||
SELECT
|
||||
<include refid="Base_Column_List" />
|
||||
FROM tb_banner
|
||||
WHERE id = #{bannerId} AND deleted = 0
|
||||
WHERE banner_id = #{bannerID} AND deleted = 0
|
||||
</select>
|
||||
|
||||
<!-- 根据状态查询Banner列表 -->
|
||||
@@ -95,18 +128,18 @@
|
||||
FROM tb_banner
|
||||
WHERE title = #{title} AND deleted = 0
|
||||
<if test="excludeId != null and excludeId != ''">
|
||||
AND id != #{excludeId}
|
||||
AND banner_id != #{excludeId}
|
||||
</if>
|
||||
</select>
|
||||
|
||||
<!-- 插入Banner -->
|
||||
<insert id="insertBanner" parameterType="org.xyzh.common.dto.resource.TbBanner">
|
||||
INSERT INTO tb_banner (
|
||||
id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
creator, updater, create_time, update_time, delete_time, deleted
|
||||
id, banner_id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
creator, create_time
|
||||
) VALUES (
|
||||
#{id}, #{title}, #{imageUrl}, #{linkType}, #{linkID}, #{linkUrl}, #{orderNum}, #{status},
|
||||
#{creator}, #{updater}, #{createTime}, #{updateTime}, #{deleteTime}, #{deleted}
|
||||
#{id}, #{bannerID}, #{title}, #{imageUrl}, #{linkType}, #{linkID}, #{linkUrl}, #{orderNum}, #{status},
|
||||
#{creator}, #{createTime}
|
||||
)
|
||||
</insert>
|
||||
|
||||
@@ -114,6 +147,9 @@
|
||||
<update id="updateBanner" parameterType="org.xyzh.common.dto.resource.TbBanner">
|
||||
UPDATE tb_banner
|
||||
<set>
|
||||
<if test="bannerID != null and bannerID != ''">
|
||||
banner_id = #{bannerID},
|
||||
</if>
|
||||
<if test="title != null and title != ''">
|
||||
title = #{title},
|
||||
</if>
|
||||
@@ -148,24 +184,24 @@
|
||||
deleted = #{deleted},
|
||||
</if>
|
||||
</set>
|
||||
WHERE id = #{id}
|
||||
WHERE banner_id = #{bannerID}
|
||||
</update>
|
||||
|
||||
<!-- 删除Banner -->
|
||||
<delete id="deleteBanner" parameterType="org.xyzh.common.dto.resource.TbBanner">
|
||||
DELETE FROM tb_banner
|
||||
WHERE id = #{id}
|
||||
WHERE banner_id = #{bannerID}
|
||||
</delete>
|
||||
|
||||
<!-- 批量插入Banner -->
|
||||
<insert id="batchInsertBanners" parameterType="java.util.List">
|
||||
INSERT INTO tb_banner (
|
||||
id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
id, banner_id, title, image_url, link_type, link_id, link_url, order_num, status,
|
||||
creator, updater, create_time, update_time, delete_time, deleted
|
||||
) VALUES
|
||||
<foreach collection="bannerList" item="item" separator=",">
|
||||
(
|
||||
#{item.id}, #{item.title}, #{item.imageUrl}, #{item.linkType}, #{item.linkID},
|
||||
#{item.id}, #{item.bannerID}, #{item.title}, #{item.imageUrl}, #{item.linkType}, #{item.linkID},
|
||||
#{item.linkUrl}, #{item.orderNum}, #{item.status}, #{item.creator}, #{item.updater},
|
||||
#{item.createTime}, #{item.updateTime}, #{item.deleteTime}, #{item.deleted}
|
||||
)
|
||||
@@ -175,9 +211,9 @@
|
||||
<!-- 批量删除Banner -->
|
||||
<delete id="batchDeleteBanners">
|
||||
DELETE FROM tb_banner
|
||||
WHERE id IN
|
||||
<foreach collection="ids" item="id" open="(" separator="," close=")">
|
||||
#{id}
|
||||
WHERE banner_id IN
|
||||
<foreach collection="ids" item="bannerID" open="(" separator="," close=")">
|
||||
#{bannerID}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
@@ -186,7 +222,7 @@
|
||||
SELECT
|
||||
<include refid="Base_Column_List" />
|
||||
FROM tb_banner
|
||||
<include refid="Where_Clause" />
|
||||
<include refid="Filter_Clause" />
|
||||
ORDER BY order_num ASC, create_time DESC
|
||||
LIMIT #{pageParam.pageSize} OFFSET #{pageParam.offset}
|
||||
</select>
|
||||
@@ -195,7 +231,7 @@
|
||||
<select id="countBanners" resultType="long">
|
||||
SELECT COUNT(1)
|
||||
FROM tb_banner
|
||||
<include refid="Where_Clause" />
|
||||
<include refid="Filter_Clause" />
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -10,11 +10,11 @@ import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.dto.study.TbCourseChapter;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.dto.study.TbLearningRecord;
|
||||
import org.xyzh.common.vo.ChapterVO;
|
||||
import org.xyzh.common.vo.CourseVO;
|
||||
import org.xyzh.common.vo.CourseItemVO;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@@ -52,24 +52,30 @@ public class CourseController {
|
||||
* 根据ID获取课程详情
|
||||
*/
|
||||
@GetMapping("/{courseID}")
|
||||
public ResultDomain<CourseVO> getCourseById(@PathVariable("courseID") String courseID) {
|
||||
public ResultDomain<CourseItemVO> getCourseById(@PathVariable("courseID") String courseID) {
|
||||
return courseService.getCourseById(courseID);
|
||||
}
|
||||
|
||||
@GetMapping("/{courseID}/progress")
|
||||
public ResultDomain<CourseItemVO> getCourseProgress(@PathVariable("courseID") String courseID) {
|
||||
return courseService.getCourseProgress(courseID);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 创建课程
|
||||
*/
|
||||
@PostMapping("/course")
|
||||
public ResultDomain<CourseVO> createCourse(@RequestBody CourseVO courseVO) {
|
||||
return courseService.createCourse(courseVO);
|
||||
public ResultDomain<CourseItemVO> createCourse(@RequestBody CourseItemVO courseItemVO) {
|
||||
return courseService.createCourse(courseItemVO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新课程基本信息
|
||||
*/
|
||||
@PutMapping("/course")
|
||||
public ResultDomain<CourseVO> updateCourse(@RequestBody CourseVO courseVO) {
|
||||
return courseService.updateCourse(courseVO);
|
||||
public ResultDomain<CourseItemVO> updateCourse(@RequestBody CourseItemVO courseItemVO) {
|
||||
return courseService.updateCourse(courseItemVO);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,8 +88,8 @@ public class LearningTaskController {
|
||||
* 删除任务
|
||||
*/
|
||||
@DeleteMapping("/task")
|
||||
public ResultDomain<Boolean> deleteTask(@PathVariable("taskID") String taskID) {
|
||||
return learningTaskService.deleteTask(taskID);
|
||||
public ResultDomain<Boolean> deleteTask(@RequestBody TbLearningTask task) {
|
||||
return learningTaskService.deleteTask(task.getTaskID());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.vo.CourseItemVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -181,4 +182,14 @@ public interface CourseMapper extends BaseMapper<TbCourse> {
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
int incrementViewCount(@Param("courseID") String courseID);
|
||||
|
||||
/**
|
||||
* @description 增加课程学习人数
|
||||
* @param courseID 课程ID
|
||||
* @param count 增加人数
|
||||
* @return int 影响行数
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
int incrementLearnCount(@Param("courseID") String courseID, @Param("count") int count);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.xyzh.common.core.page.PageParam;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.vo.CourseItemVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -35,6 +36,16 @@ public interface CourseNodeMapper extends BaseMapper<TbCourseNode> {
|
||||
* @since 2025-10-21
|
||||
*/
|
||||
List<TbCourseNode> selectCourseNodesByChapterIDs(@Param("chapterIDs") List<String> chapterIDs);
|
||||
|
||||
/**
|
||||
* @description 查询节点进度
|
||||
* @param chapterIDs 章节ID列表
|
||||
* @param userID 用户ID(可选,传null则不关联学习记录)
|
||||
* @return List<CourseItemVO> 节点进度列表
|
||||
* @author yslg
|
||||
* @since 2025-10-28
|
||||
*/
|
||||
List<CourseItemVO> selectNodesProgress(@Param("chapterIDs") List<String> chapterIDs, @Param("userID") String userID);
|
||||
/**
|
||||
* @description 根据节点ID查询节点信息
|
||||
* @param nodeId 节点ID
|
||||
|
||||
@@ -20,14 +20,10 @@ import org.xyzh.common.core.page.PageRequest;
|
||||
import org.xyzh.common.dto.study.TbCourse;
|
||||
import org.xyzh.common.dto.study.TbCourseChapter;
|
||||
import org.xyzh.common.dto.study.TbCourseNode;
|
||||
import org.xyzh.common.dto.study.TbCourseTag;
|
||||
import org.xyzh.common.dto.study.TbLearningRecord;
|
||||
import org.xyzh.common.dto.user.TbSysUser;
|
||||
import org.xyzh.common.utils.IDUtils;
|
||||
import org.xyzh.common.vo.ChapterVO;
|
||||
import org.xyzh.common.vo.CourseVO;
|
||||
import org.xyzh.common.vo.CourseItemVO;
|
||||
import org.xyzh.study.mapper.CourseMapper;
|
||||
import org.xyzh.study.mapper.CourseTagMapper;
|
||||
import org.xyzh.study.mapper.CourseChapterMapper;
|
||||
import org.xyzh.study.mapper.CourseNodeMapper;
|
||||
import org.xyzh.study.service.SCCourseService;
|
||||
@@ -51,9 +47,6 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
@Autowired
|
||||
private CourseChapterMapper courseChapterMapper;
|
||||
|
||||
@Autowired
|
||||
private CourseTagMapper courseTagMapper;
|
||||
|
||||
@Autowired
|
||||
private CourseNodeMapper courseNodeMapper;
|
||||
|
||||
@@ -81,108 +74,133 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<CourseVO> getCourseById(String courseID) {
|
||||
ResultDomain<CourseVO> resultDomain = new ResultDomain<>();
|
||||
public ResultDomain<CourseItemVO> getCourseById(String courseID) {
|
||||
ResultDomain<CourseItemVO> resultDomain = new ResultDomain<>();
|
||||
// 查询课程
|
||||
TbCourse course = courseMapper.selectByCourseId(courseID);
|
||||
// 查询标签
|
||||
List<TbCourseTag> tags = courseTagMapper.selectByCourseId(courseID);
|
||||
CourseVO courseVO = new CourseVO();
|
||||
courseVO.setCourse(course);
|
||||
courseVO.setCourseTags(tags);
|
||||
if (course == null) {
|
||||
resultDomain.fail("课程不存在");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 从课程实体创建CourseItemVO
|
||||
CourseItemVO courseItemVO = CourseItemVO.fromCourse(course);
|
||||
|
||||
// 查询课程章节
|
||||
TbCourseChapter filter = new TbCourseChapter();
|
||||
filter.setCourseID(courseID);
|
||||
List<TbCourseChapter> chapters = courseChapterMapper.selectCourseChapters(filter);
|
||||
// 查询子节点
|
||||
if (chapters.size() > 0) {
|
||||
List<String> chapterIDs = chapters.stream().map(TbCourseChapter::getChapterID).collect(Collectors.toList());
|
||||
|
||||
// 查询并构建章节及节点结构
|
||||
if (!chapters.isEmpty()) {
|
||||
List<String> chapterIDs = chapters.stream()
|
||||
.map(TbCourseChapter::getChapterID)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 查询所有节点
|
||||
List<TbCourseNode> nodes = courseNodeMapper.selectCourseNodesByChapterIDs(chapterIDs);
|
||||
Map<String, List<TbCourseNode>> nodesMap = nodes.stream().collect(Collectors.groupingBy(TbCourseNode::getChapterID));
|
||||
List<ChapterVO> chapterVOs = chapters.stream().map(chapter -> {
|
||||
ChapterVO chapterVO = new ChapterVO();
|
||||
chapterVO.setChapter(chapter);
|
||||
chapterVO.setNodes(nodesMap.get(chapter.getChapterID()));
|
||||
return chapterVO;
|
||||
}).collect(Collectors.toList());
|
||||
courseVO.setCourseChapters(chapterVOs);
|
||||
|
||||
// 转换章节为CourseItemVO列表
|
||||
List<CourseItemVO> chapterVOs = chapters.stream()
|
||||
.map(CourseItemVO::fromChapter)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 转换节点为CourseItemVO,并按章节ID分组
|
||||
Map<String, List<CourseItemVO>> nodesMap = nodes.stream()
|
||||
.map(CourseItemVO::fromNode)
|
||||
.collect(Collectors.groupingBy(CourseItemVO::getChapterID));
|
||||
|
||||
// 设置章节列表和章节节点映射
|
||||
courseItemVO.setChapters(chapterVOs);
|
||||
courseItemVO.setChapterNodes(nodesMap);
|
||||
}
|
||||
|
||||
resultDomain.success("获取课程详情成功", courseVO);
|
||||
resultDomain.success("获取课程详情成功", courseItemVO);
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<CourseVO> createCourse(CourseVO courseVO) {
|
||||
ResultDomain<CourseVO> resultDomain = new ResultDomain<>();
|
||||
public ResultDomain<CourseItemVO> createCourse(CourseItemVO courseItemVO) {
|
||||
ResultDomain<CourseItemVO> resultDomain = new ResultDomain<>();
|
||||
TbSysUser user = LoginUtil.getCurrentUser();
|
||||
if (user == null) {
|
||||
resultDomain.fail("请先登录");
|
||||
return resultDomain;
|
||||
}
|
||||
TbCourse course = courseVO.getCourse();
|
||||
|
||||
// 转换为课程实体并保存
|
||||
TbCourse course = courseItemVO.toCourse();
|
||||
String courseID = IDUtils.generateID();
|
||||
course.setID(IDUtils.generateID());
|
||||
course.setCreator(user.getID());
|
||||
course.setCourseID(courseID);
|
||||
Date now = new Date();
|
||||
course.setCreateTime(now);
|
||||
course.setStatus(0);
|
||||
|
||||
|
||||
if (course.getStatus() == null) {
|
||||
course.setStatus(0);
|
||||
}
|
||||
courseMapper.insertCourse(course);
|
||||
courseItemVO.setCourseID(courseID);
|
||||
|
||||
List<ChapterVO> courseChapters = courseVO.getCourseChapters();
|
||||
List<TbCourseChapter> chapters = new ArrayList<>();
|
||||
List<TbCourseNode> nodes = new ArrayList<>();
|
||||
int length = courseChapters.size();
|
||||
for (int i=0; i<length; i++) {
|
||||
ChapterVO chapterVO = courseChapters.get(i);
|
||||
TbCourseChapter chapter = chapterVO.getChapter();
|
||||
List<TbCourseNode> nodesList = chapterVO.getNodes();
|
||||
// 处理章节和节点
|
||||
List<CourseItemVO> chapterVOs = courseItemVO.getChapters();
|
||||
if (chapterVOs != null && !chapterVOs.isEmpty()) {
|
||||
List<TbCourseChapter> chapters = new ArrayList<>();
|
||||
List<TbCourseNode> allNodes = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < chapterVOs.size(); i++) {
|
||||
CourseItemVO chapterVO = chapterVOs.get(i);
|
||||
TbCourseChapter chapter = chapterVO.toChapter();
|
||||
|
||||
String chapterID = IDUtils.generateID();
|
||||
chapter.setID(IDUtils.generateID());
|
||||
chapter.setChapterID(chapterID);
|
||||
chapter.setCourseID(courseID);
|
||||
chapter.setCreator(user.getID());
|
||||
chapter.setCreateTime(now);
|
||||
chapter.setOrderNum(i);
|
||||
chapters.add(chapter);
|
||||
|
||||
// 更新chapterVO中的ID
|
||||
chapterVO.setChapterID(chapterID);
|
||||
|
||||
chapter.setCourseID(courseID);
|
||||
String chapterID = IDUtils.generateID();
|
||||
chapter.setID(IDUtils.generateID());
|
||||
chapter.setChapterID(chapterID);
|
||||
chapter.setCreator(user.getID());
|
||||
chapter.setCreateTime(now);
|
||||
chapter.setOrderNum(i);
|
||||
chapters.add(chapter);
|
||||
|
||||
for (int j=0; j<nodesList.size(); j++) {
|
||||
TbCourseNode node = nodesList.get(j);
|
||||
node.setNodeID(IDUtils.generateID());
|
||||
node.setChapterID(chapterID);
|
||||
node.setCreator(user.getID());
|
||||
node.setCreateTime(now);
|
||||
nodes.add(node);
|
||||
// 处理该章节的节点
|
||||
List<CourseItemVO> nodeVOs = chapterVO.getChapters(); // 节点存储在chapters字段中
|
||||
if (nodeVOs != null && !nodeVOs.isEmpty()) {
|
||||
for (int j = 0; j < nodeVOs.size(); j++) {
|
||||
CourseItemVO nodeVO = nodeVOs.get(j);
|
||||
TbCourseNode node = nodeVO.toNode();
|
||||
|
||||
String nodeID = IDUtils.generateID();
|
||||
node.setID(IDUtils.generateID());
|
||||
node.setNodeID(nodeID);
|
||||
node.setChapterID(chapterID);
|
||||
node.setCreator(user.getID());
|
||||
node.setCreateTime(now);
|
||||
node.setOrderNum(j);
|
||||
allNodes.add(node);
|
||||
|
||||
// 更新nodeVO中的ID
|
||||
nodeVO.setNodeID(nodeID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!chapters.isEmpty()) {
|
||||
courseChapterMapper.batchInsertCourseChapters(chapters);
|
||||
}
|
||||
if (!allNodes.isEmpty()) {
|
||||
courseNodeMapper.batchInsertCourseNodes(allNodes);
|
||||
}
|
||||
}
|
||||
courseChapterMapper.batchInsertCourseChapters(chapters);
|
||||
courseNodeMapper.batchInsertCourseNodes(nodes);
|
||||
|
||||
List<TbCourseTag> courseTags = courseVO.getCourseTags();
|
||||
length = courseTags.size();
|
||||
if (length > 0) {
|
||||
for (int i=0; i<length; i++) {
|
||||
TbCourseTag tag = courseTags.get(i);
|
||||
tag.setCourseID(courseID);
|
||||
tag.setTagID(IDUtils.generateID());
|
||||
tag.setCreator(user.getID());
|
||||
tag.setCreateTime(now);
|
||||
}
|
||||
courseTagMapper.batchInsertCourseTags(courseTags);
|
||||
}
|
||||
|
||||
|
||||
resultDomain.success("创建课程成功",courseVO);
|
||||
resultDomain.success("创建课程成功", courseItemVO);
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<CourseVO> updateCourse(CourseVO courseVO) {
|
||||
ResultDomain<CourseVO> resultDomain = new ResultDomain<>();
|
||||
public ResultDomain<CourseItemVO> updateCourse(CourseItemVO courseItemVO) {
|
||||
ResultDomain<CourseItemVO> resultDomain = new ResultDomain<>();
|
||||
|
||||
TbSysUser user = LoginUtil.getCurrentUser();
|
||||
if (user == null) {
|
||||
@@ -190,8 +208,7 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
TbCourse course = courseVO.getCourse();
|
||||
String courseID = course.getCourseID();
|
||||
String courseID = courseItemVO.getCourseID();
|
||||
|
||||
if (courseID == null || courseID.isEmpty()) {
|
||||
resultDomain.fail("课程ID不能为空");
|
||||
@@ -199,12 +216,14 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
}
|
||||
|
||||
// 1. 更新课程基本信息
|
||||
TbCourse course = courseItemVO.toCourse();
|
||||
course.setUpdater(user.getID());
|
||||
course.setUpdateTime(new Date());
|
||||
Date now = new Date();
|
||||
course.setUpdateTime(now);
|
||||
courseMapper.updateCourse(course);
|
||||
|
||||
// 2. 处理章节和节点
|
||||
List<ChapterVO> newChapterVOs = courseVO.getCourseChapters();
|
||||
List<CourseItemVO> newChapterVOs = courseItemVO.getChapters();
|
||||
if (newChapterVOs == null) {
|
||||
newChapterVOs = new ArrayList<>();
|
||||
}
|
||||
@@ -235,12 +254,11 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
List<TbCourseNode> nodesToUpdate = new ArrayList<>();
|
||||
|
||||
Set<String> newChapterIDs = new HashSet<>();
|
||||
Date now = new Date();
|
||||
|
||||
// 遍历新的章节
|
||||
for (int i = 0; i < newChapterVOs.size(); i++) {
|
||||
ChapterVO chapterVO = newChapterVOs.get(i);
|
||||
TbCourseChapter chapter = chapterVO.getChapter();
|
||||
CourseItemVO chapterVO = newChapterVOs.get(i);
|
||||
TbCourseChapter chapter = chapterVO.toChapter();
|
||||
String chapterID = chapter.getChapterID();
|
||||
|
||||
chapter.setCourseID(courseID);
|
||||
@@ -266,9 +284,9 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
newChapterIDs.add(chapterID);
|
||||
|
||||
// 处理该章节的节点
|
||||
List<TbCourseNode> newNodes = chapterVO.getNodes();
|
||||
if (newNodes == null) {
|
||||
newNodes = new ArrayList<>();
|
||||
List<CourseItemVO> newNodeVOs = chapterVO.getChapters(); // 节点存储在chapters字段中
|
||||
if (newNodeVOs == null) {
|
||||
newNodeVOs = new ArrayList<>();
|
||||
}
|
||||
|
||||
List<TbCourseNode> existingNodesForChapter = existingNodesMap.getOrDefault(chapterID, new ArrayList<>());
|
||||
@@ -278,8 +296,9 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
Set<String> newNodeIDs = new HashSet<>();
|
||||
|
||||
// 遍历新的节点
|
||||
for (int j = 0; j < newNodes.size(); j++) {
|
||||
TbCourseNode node = newNodes.get(j);
|
||||
for (int j = 0; j < newNodeVOs.size(); j++) {
|
||||
CourseItemVO nodeVO = newNodeVOs.get(j);
|
||||
TbCourseNode node = nodeVO.toNode();
|
||||
String nodeID = node.getNodeID();
|
||||
|
||||
node.setChapterID(chapterID);
|
||||
@@ -349,50 +368,7 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 处理标签
|
||||
List<TbCourseTag> newTags = courseVO.getCourseTags();
|
||||
if (newTags == null) {
|
||||
newTags = new ArrayList<>();
|
||||
}
|
||||
|
||||
// 获取现有标签
|
||||
List<TbCourseTag> existingTags = courseTagMapper.selectByCourseId(courseID);
|
||||
Map<String, TbCourseTag> existingTagMap = existingTags.stream()
|
||||
.collect(Collectors.toMap(TbCourseTag::getTagID, tag -> tag));
|
||||
|
||||
Set<String> newTagIDs = new HashSet<>();
|
||||
List<TbCourseTag> tagsToInsert = new ArrayList<>();
|
||||
|
||||
// 处理新标签
|
||||
for (TbCourseTag tag : newTags) {
|
||||
String tagID = tag.getTagID();
|
||||
|
||||
if (tagID == null || tagID.isEmpty() || !existingTagMap.containsKey(tagID)) {
|
||||
// 新增标签
|
||||
tag.setID(IDUtils.generateID());
|
||||
tag.setCourseID(courseID);
|
||||
tag.setCreator(user.getID());
|
||||
tag.setCreateTime(now);
|
||||
tagsToInsert.add(tag);
|
||||
} else {
|
||||
newTagIDs.add(tagID);
|
||||
}
|
||||
}
|
||||
|
||||
// 找出要删除的标签
|
||||
List<String> tagIDsToDelete = existingTagMap.keySet().stream()
|
||||
.filter(id -> !newTagIDs.contains(id))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!tagIDsToDelete.isEmpty()) {
|
||||
courseTagMapper.batchDeleteCourseTags(tagIDsToDelete);
|
||||
}
|
||||
|
||||
if (!tagsToInsert.isEmpty()) {
|
||||
courseTagMapper.batchInsertCourseTags(tagsToInsert);
|
||||
}
|
||||
|
||||
resultDomain.success("更新课程成功", courseVO);
|
||||
resultDomain.success("更新课程成功", courseItemVO);
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
@@ -544,4 +520,57 @@ public class SCCourseServiceImpl implements SCCourseService {
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<CourseItemVO> getCourseProgress(String courseID) {
|
||||
ResultDomain<CourseItemVO> resultDomain = new ResultDomain<>();
|
||||
|
||||
// 获取当前用户
|
||||
TbSysUser user = LoginUtil.getCurrentUser();
|
||||
if (user == null) {
|
||||
resultDomain.fail("用户未登录");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 查询课程
|
||||
TbCourse course = courseMapper.selectByCourseId(courseID);
|
||||
if (course == null) {
|
||||
resultDomain.fail("课程不存在");
|
||||
return resultDomain;
|
||||
}
|
||||
|
||||
// 从课程实体创建CourseItemVO
|
||||
CourseItemVO courseItemVO = CourseItemVO.fromCourse(course);
|
||||
|
||||
// 查询课程章节
|
||||
TbCourseChapter filter = new TbCourseChapter();
|
||||
filter.setCourseID(courseID);
|
||||
List<TbCourseChapter> chapters = courseChapterMapper.selectCourseChapters(filter);
|
||||
|
||||
// 查询并构建章节及节点结构(带进度)
|
||||
if (!chapters.isEmpty()) {
|
||||
List<String> chapterIDs = chapters.stream()
|
||||
.map(TbCourseChapter::getChapterID)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 查询带进度的节点(传入用户ID)
|
||||
List<CourseItemVO> nodesWithProgress = courseNodeMapper.selectNodesProgress(chapterIDs, user.getID());
|
||||
|
||||
// 转换章节为CourseItemVO列表
|
||||
List<CourseItemVO> chapterVOs = chapters.stream()
|
||||
.map(CourseItemVO::fromChapter)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 按章节ID分组节点
|
||||
Map<String, List<CourseItemVO>> nodesMap = nodesWithProgress.stream()
|
||||
.collect(Collectors.groupingBy(CourseItemVO::getChapterID));
|
||||
|
||||
// 设置章节列表和章节节点映射
|
||||
courseItemVO.setChapters(chapterVOs);
|
||||
courseItemVO.setChapterNodes(nodesMap);
|
||||
}
|
||||
|
||||
resultDomain.success("获取课程进度成功", courseItemVO);
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import org.xyzh.common.vo.TaskItemVO;
|
||||
import org.xyzh.common.vo.TaskVO;
|
||||
import org.xyzh.study.mapper.LearningTaskMapper;
|
||||
import org.xyzh.study.mapper.TaskUserMapper;
|
||||
import org.xyzh.study.mapper.CourseMapper;
|
||||
import org.xyzh.system.utils.LoginUtil;
|
||||
import org.xyzh.study.mapper.TaskItemMapper;
|
||||
import org.xyzh.api.study.task.LearningTaskService;
|
||||
@@ -48,6 +49,9 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
@Autowired
|
||||
private TaskUserMapper taskUserMapper;
|
||||
|
||||
@Autowired
|
||||
private CourseMapper courseMapper;
|
||||
|
||||
@Autowired
|
||||
private TaskItemMapper taskItemMapper;
|
||||
|
||||
@@ -82,7 +86,8 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
taskUsers.add(taskUser);
|
||||
}
|
||||
int result = taskUserMapper.batchInsertTaskUsers(taskUsers);
|
||||
if (result > 0) {
|
||||
int learnCount = courseMapper.incrementLearnCount(taskID, userIDs.size());
|
||||
if (result > 0 && learnCount > 0) {
|
||||
resultDomain.success("添加任务用户成功", taskUsers);
|
||||
return resultDomain;
|
||||
} else {
|
||||
@@ -181,6 +186,10 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
item.setCreateTime(now);
|
||||
});
|
||||
taskUserMapper.batchInsertTaskUsers(taskUsers);
|
||||
for(TbTaskItem item : taskCourses) {
|
||||
int learnCount = courseMapper.incrementLearnCount(item.getItemID(), taskUsers.size());
|
||||
|
||||
}
|
||||
|
||||
resultDomain.success("创建任务成功", taskVO);
|
||||
return resultDomain;
|
||||
@@ -349,6 +358,7 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
|
||||
if (!usersToInsert.isEmpty()) {
|
||||
taskUserMapper.batchInsertTaskUsers(usersToInsert);
|
||||
int learnCount = courseMapper.incrementLearnCount(taskID, usersToInsert.size());
|
||||
}
|
||||
|
||||
resultDomain.success("更新任务成功", taskVO);
|
||||
@@ -357,8 +367,17 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
|
||||
@Override
|
||||
public ResultDomain<Boolean> deleteTask(String taskID) {
|
||||
// TODO Auto-generated method stub
|
||||
return null;
|
||||
ResultDomain<Boolean> resultDomain = new ResultDomain<>();
|
||||
TbLearningTask filter = new TbLearningTask();
|
||||
filter.setTaskID(taskID);
|
||||
int result = learningTaskMapper.deleteLearningTask(filter);
|
||||
if (result > 0) {
|
||||
resultDomain.success("删除任务成功", true);
|
||||
return resultDomain;
|
||||
} else {
|
||||
resultDomain.fail("删除任务失败");
|
||||
return resultDomain;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -184,7 +184,7 @@
|
||||
teacher, status, view_count, learn_count, order_num, creator, create_time
|
||||
) VALUES (
|
||||
#{id}, #{courseID}, #{name}, #{coverImage}, #{description}, #{content}, #{duration},
|
||||
#{teacher}, #{status}, #{viewCount}, #{learnCount}, #{orderNum}, #{creator},#{createTime}
|
||||
#{teacher}, #{status}, 0, 0, #{orderNum}, #{creator},#{createTime}
|
||||
)
|
||||
</insert>
|
||||
|
||||
@@ -248,15 +248,14 @@
|
||||
<insert id="batchInsertCourses" parameterType="java.util.List">
|
||||
INSERT INTO tb_course (
|
||||
id, course_id, name, cover_image, description, content, duration,
|
||||
teacher, status, view_count, learn_count, order_num, creator, updater,
|
||||
create_time, update_time, delete_time, deleted
|
||||
teacher, status, view_count, learn_count, order_num, creator,
|
||||
create_time, delete_time, deleted
|
||||
) VALUES
|
||||
<foreach collection="courseList" item="item" separator=",">
|
||||
(
|
||||
#{item.id}, #{item.courseID}, #{item.name}, #{item.coverImage}, #{item.description},
|
||||
#{item.content}, #{item.duration}, #{item.teacher}, #{item.status}, #{item.viewCount},
|
||||
#{item.learnCount}, #{item.orderNum}, #{item.creator}, #{item.updater},
|
||||
#{item.createTime}, #{item.updateTime}, #{item.deleteTime}, #{item.deleted}
|
||||
#{item.content}, #{item.duration}, #{item.teacher}, #{item.status}, 0,
|
||||
0, #{item.orderNum}, #{item.creator}, #{item.createTime}, #{item.deleteTime}, #{item.deleted}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
@@ -292,4 +291,14 @@
|
||||
SET view_count = view_count + 1
|
||||
WHERE course_id = #{courseID} AND deleted = 0
|
||||
</update>
|
||||
|
||||
<!-- incrementLearnCount -->
|
||||
|
||||
<update id="incrementLearnCount">
|
||||
UPDATE tb_course
|
||||
SET learn_count = learn_count + #{count}
|
||||
WHERE course_id = #{courseID} AND deleted = 0
|
||||
</update>
|
||||
|
||||
|
||||
</mapper>
|
||||
@@ -23,6 +23,27 @@
|
||||
<result column="deleted" property="deleted" jdbcType="BOOLEAN"/>
|
||||
</resultMap>
|
||||
|
||||
<resultMap id="CourseItemResultMap" type="org.xyzh.common.vo.CourseItemVO">
|
||||
<id column="id" property="id" jdbcType="VARCHAR"/>
|
||||
<result column="node_id" property="nodeID" jdbcType="VARCHAR"/>
|
||||
<result column="chapter_id" property="chapterID" jdbcType="VARCHAR"/>
|
||||
<result column="name" property="name" jdbcType="VARCHAR"/>
|
||||
<result column="content" property="content" jdbcType="LONGVARCHAR"/>
|
||||
<result column="node_type" property="nodeType" jdbcType="INTEGER"/>
|
||||
<result column="resource_id" property="resourceID" jdbcType="VARCHAR"/>
|
||||
<result column="video_url" property="videoUrl" jdbcType="VARCHAR"/>
|
||||
<result column="duration" property="duration" jdbcType="INTEGER"/>
|
||||
<result column="order_num" property="orderNum" jdbcType="INTEGER"/>
|
||||
<result column="is_required" property="isRequired" jdbcType="INTEGER"/>
|
||||
<result column="create_time" property="createTime" jdbcType="TIMESTAMP"/>
|
||||
<!-- 学习记录相关字段 -->
|
||||
<result column="progress" property="progress" jdbcType="DECIMAL"/>
|
||||
<result column="is_complete" property="isComplete" jdbcType="BOOLEAN"/>
|
||||
<result column="learning_duration" property="learningDuration" jdbcType="INTEGER"/>
|
||||
<result column="last_learn_time" property="lastLearnTime" jdbcType="TIMESTAMP"/>
|
||||
<result column="complete_time" property="completeTime" jdbcType="TIMESTAMP"/>
|
||||
</resultMap>
|
||||
|
||||
<!-- 基础字段 -->
|
||||
<sql id="Base_Column_List">
|
||||
id, node_id, chapter_id, name, content, node_type, resource_id, video_url, duration, order_num, is_required,
|
||||
@@ -236,4 +257,41 @@
|
||||
AND deleted = 0
|
||||
ORDER BY order_num ASC, create_time ASC
|
||||
</select>
|
||||
<!-- selectNodesProgress -->
|
||||
|
||||
<select id="selectNodesProgress" resultMap="CourseItemResultMap">
|
||||
SELECT
|
||||
tcn.id,
|
||||
tcn.node_id,
|
||||
tcn.chapter_id,
|
||||
tcn.`name`,
|
||||
tcn.content,
|
||||
tcn.node_type,
|
||||
tcn.resource_id,
|
||||
tcn.video_url,
|
||||
tcn.duration,
|
||||
tcn.order_num,
|
||||
tcn.is_required,
|
||||
tcn.create_time,
|
||||
tlr.duration as learning_duration,
|
||||
tlr.progress,
|
||||
tlr.is_complete,
|
||||
tlr.last_learn_time,
|
||||
tlr.complete_time
|
||||
FROM
|
||||
tb_course_node tcn
|
||||
LEFT JOIN tb_learning_record tlr ON tcn.node_id = tlr.node_id
|
||||
<if test="userID != null and userID != ''">
|
||||
AND tlr.user_id = #{userID}
|
||||
</if>
|
||||
WHERE
|
||||
tcn.chapter_id IN
|
||||
<foreach collection="chapterIDs" item="chapterID" open="(" separator="," close=")">
|
||||
#{chapterID}
|
||||
</foreach>
|
||||
AND tcn.deleted = 0
|
||||
ORDER BY
|
||||
tcn.order_num ASC,
|
||||
tcn.create_time ASC
|
||||
</select>
|
||||
</mapper>
|
||||
|
||||
@@ -125,11 +125,9 @@
|
||||
<!-- 插入学习记录 -->
|
||||
<insert id="insertLearningRecord" parameterType="org.xyzh.common.dto.study.TbLearningRecord">
|
||||
INSERT INTO tb_learning_record (
|
||||
id, user_id, task_id, resource_type, resource_id, course_id, chapter_id, node_id, duration, progress,
|
||||
is_complete, complete_time, last_learn_time, create_time
|
||||
id, user_id, task_id, resource_type, resource_id, course_id, chapter_id, node_id, create_time
|
||||
) VALUES (
|
||||
#{id}, #{userID}, #{taskID}, #{resourceType}, #{resourceID}, #{courseID}, #{chapterID}, #{nodeID}, #{duration}, #{progress},
|
||||
#{isComplete}, #{completeTime}, #{lastLearnTime}, #{createTime}
|
||||
#{id}, #{userID}, #{taskID}, #{resourceType}, #{resourceID}, #{courseID}, #{chapterID}, #{nodeID}, #{createTime}
|
||||
)
|
||||
</insert>
|
||||
|
||||
@@ -171,14 +169,11 @@
|
||||
<!-- 批量插入学习记录 -->
|
||||
<insert id="batchInsertLearningRecords" parameterType="java.util.List">
|
||||
INSERT INTO tb_learning_record (
|
||||
id, user_id, resource_type, resource_id, task_id, duration, progress,
|
||||
is_complete, complete_time, last_learn_time, create_time, update_time
|
||||
id, user_id, resource_type, resource_id, task_id, course_id, chapter_id, node_id, create_time
|
||||
) VALUES
|
||||
<foreach collection="learningRecordList" item="item" separator=",">
|
||||
(
|
||||
#{item.id}, #{item.userID}, #{item.resourceType}, #{item.resourceID}, #{item.taskID},
|
||||
#{item.duration}, #{item.progress}, #{item.isComplete}, #{item.completeTime},
|
||||
#{item.lastLearnTime}, #{item.createTime}, #{item.updateTime}
|
||||
#{item.id}, #{item.userID}, #{item.resourceType}, #{item.resourceID}, #{item.taskID}, #{item.courseID}, #{item.chapterID}, #{item.nodeID}, #{item.createTime}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
@@ -152,9 +152,9 @@
|
||||
<!-- 插入任务用户关联 -->
|
||||
<insert id="insertTaskUser" parameterType="org.xyzh.common.dto.study.TbTaskUser">
|
||||
INSERT INTO tb_task_user (
|
||||
id, task_id, user_id, dept_id,creator, create_time
|
||||
id, task_id, user_id, dept_id,creator, status, progress, create_time
|
||||
) VALUES (
|
||||
#{id}, #{taskID}, #{userID}, #{deptID}, #{creator}, #{createTime}
|
||||
#{id}, #{taskID}, #{userID}, #{deptID}, #{creator}, 0, 0, #{createTime}
|
||||
)
|
||||
</insert>
|
||||
|
||||
@@ -193,8 +193,8 @@
|
||||
) VALUES
|
||||
<foreach collection="taskUserList" item="item" separator=",">
|
||||
(
|
||||
#{item.id}, #{item.taskID}, #{item.userID}, #{item.deptID}, #{item.status},
|
||||
#{item.progress}, #{item.completeTime}, #{item.creator}, #{item.createTime}
|
||||
#{item.id}, #{item.taskID}, #{item.userID}, #{item.deptID}, 0,
|
||||
0, #{item.completeTime}, #{item.creator}, #{item.createTime}
|
||||
)
|
||||
</foreach>
|
||||
</insert>
|
||||
|
||||
8
schoolNewsWeb/public/img/admin/agent.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.99999 5.33317V2.6665H5.33333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 5.3335H4.00001C3.26363 5.3335 2.66667 5.93045 2.66667 6.66683V12.0002C2.66667 12.7365 3.26363 13.3335 4.00001 13.3335H12C12.7364 13.3335 13.3333 12.7365 13.3333 12.0002V6.66683C13.3333 5.93045 12.7364 5.3335 12 5.3335Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.33333 9.3335H2.66666" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 9.3335H14.6667" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 8.6665V9.99984" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 8.6665V9.99984" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
4
schoolNewsWeb/public/img/admin/course.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4.6665V13.9998" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1.99999 12C1.82318 12 1.65361 11.9298 1.52859 11.8047C1.40357 11.6797 1.33333 11.5101 1.33333 11.3333V2.66667C1.33333 2.48986 1.40357 2.32029 1.52859 2.19526C1.65361 2.07024 1.82318 2 1.99999 2H5.33333C6.04057 2 6.71885 2.28095 7.21895 2.78105C7.71904 3.28115 7.99999 3.95942 7.99999 4.66667C7.99999 3.95942 8.28095 3.28115 8.78104 2.78105C9.28114 2.28095 9.95942 2 10.6667 2H14C14.1768 2 14.3464 2.07024 14.4714 2.19526C14.5964 2.32029 14.6667 2.48986 14.6667 2.66667V11.3333C14.6667 11.5101 14.5964 11.6797 14.4714 11.8047C14.3464 11.9298 14.1768 12 14 12H9.99999C9.46956 12 8.96085 12.2107 8.58578 12.5858C8.21071 12.9609 7.99999 13.4696 7.99999 14C7.99999 13.4696 7.78928 12.9609 7.41421 12.5858C7.03914 12.2107 6.53043 12 5.99999 12H1.99999Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
5
schoolNewsWeb/public/img/admin/logs.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 1.3335H4.00001C3.64638 1.3335 3.30724 1.47397 3.0572 1.72402C2.80715 1.97407 2.66667 2.31321 2.66667 2.66683V13.3335C2.66667 13.6871 2.80715 14.0263 3.0572 14.2763C3.30724 14.5264 3.64638 14.6668 4.00001 14.6668H12C12.3536 14.6668 12.6928 14.5264 12.9428 14.2763C13.1929 14.0263 13.3333 13.6871 13.3333 13.3335V4.66683L10 1.3335Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.33333 1.3335V4.00016C9.33333 4.35378 9.4738 4.69292 9.72385 4.94297C9.9739 5.19302 10.313 5.3335 10.6667 5.3335H13.3333" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 9.99984L7.33333 11.3332L10 8.6665" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 894 B |
6
schoolNewsWeb/public/img/admin/overview.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 2H2.66667C2.29848 2 2 2.29848 2 2.66667V7.33333C2 7.70152 2.29848 8 2.66667 8H6C6.36819 8 6.66667 7.70152 6.66667 7.33333V2.66667C6.66667 2.29848 6.36819 2 6 2Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 2H9.99999C9.63181 2 9.33333 2.29848 9.33333 2.66667V4.66667C9.33333 5.03486 9.63181 5.33333 9.99999 5.33333H13.3333C13.7015 5.33333 14 5.03486 14 4.66667V2.66667C14 2.29848 13.7015 2 13.3333 2Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 8H9.99999C9.63181 8 9.33333 8.29848 9.33333 8.66667V13.3333C9.33333 13.7015 9.63181 14 9.99999 14H13.3333C13.7015 14 14 13.7015 14 13.3333V8.66667C14 8.29848 13.7015 8 13.3333 8Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 10.6665H2.66667C2.29848 10.6665 2 10.965 2 11.3332V13.3332C2 13.7014 2.29848 13.9998 2.66667 13.9998H6C6.36819 13.9998 6.66667 13.7014 6.66667 13.3332V11.3332C6.66667 10.965 6.36819 10.6665 6 10.6665Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
4
schoolNewsWeb/public/img/admin/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.44734 2.75711C6.48407 2.37067 6.66356 2.01181 6.95074 1.75063C7.23792 1.48945 7.61216 1.34473 8.00034 1.34473C8.38852 1.34473 8.76276 1.48945 9.04994 1.75063C9.33712 2.01181 9.51661 2.37067 9.55334 2.75711C9.57542 3.00675 9.65731 3.24739 9.79209 3.45867C9.92688 3.66995 10.1106 3.84565 10.3276 3.97089C10.5447 4.09614 10.7888 4.16724 11.0391 4.17819C11.2895 4.18913 11.5388 4.1396 11.766 4.03378C12.1187 3.87363 12.5185 3.85045 12.8873 3.96877C13.2562 4.08708 13.5679 4.33841 13.7617 4.67385C13.9555 5.00928 14.0175 5.40482 13.9358 5.78349C13.854 6.16215 13.6343 6.49685 13.3193 6.72244C13.1143 6.86634 12.9469 7.05751 12.8313 7.27979C12.7157 7.50207 12.6554 7.74891 12.6554 7.99944C12.6554 8.24997 12.7157 8.49681 12.8313 8.71909C12.9469 8.94137 13.1143 9.13254 13.3193 9.27644C13.6343 9.50203 13.854 9.83673 13.9358 10.2154C14.0175 10.5941 13.9555 10.9896 13.7617 11.325C13.5679 11.6605 13.2562 11.9118 12.8873 12.0301C12.5185 12.1484 12.1187 12.1253 11.766 11.9651C11.5388 11.8593 11.2895 11.8097 11.0391 11.8207C10.7888 11.8316 10.5447 11.9027 10.3276 12.028C10.1106 12.1532 9.92688 12.3289 9.79209 12.5402C9.65731 12.7515 9.57542 12.9921 9.55334 13.2418C9.51661 13.6282 9.33712 13.9871 9.04994 14.2483C8.76276 14.5094 8.38852 14.6542 8.00034 14.6542C7.61216 14.6542 7.23792 14.5094 6.95074 14.2483C6.66356 13.9871 6.48407 13.6282 6.44734 13.2418C6.4253 12.992 6.3434 12.7513 6.20858 12.54C6.07376 12.3286 5.88999 12.1528 5.67283 12.0276C5.45567 11.9023 5.21152 11.8313 4.96106 11.8204C4.7106 11.8095 4.46121 11.8591 4.234 11.9651C3.88126 12.1253 3.48156 12.1484 3.11267 12.0301C2.74379 11.9118 2.43212 11.6605 2.23833 11.325C2.04453 10.9896 1.98248 10.5941 2.06424 10.2154C2.14601 9.83673 2.36574 9.50203 2.68067 9.27644C2.88575 9.13254 3.05316 8.94137 3.16873 8.71909C3.2843 8.49681 3.34464 8.24997 3.34464 7.99944C3.34464 7.74891 3.2843 7.50207 3.16873 7.27979C3.05316 7.05751 2.88575 6.86634 2.68067 6.72244C2.36618 6.49674 2.14684 6.16217 2.06527 5.78376C1.98371 5.40535 2.04574 5.01014 2.23933 4.67492C2.43291 4.3397 2.74421 4.08843 3.11273 3.96994C3.48125 3.85144 3.88066 3.8742 4.23334 4.03378C4.46051 4.1396 4.70983 4.18913 4.96021 4.17819C5.21058 4.16724 5.45463 4.09614 5.6717 3.97089C5.88877 3.84565 6.07247 3.66995 6.20725 3.45867C6.34203 3.24739 6.42393 3.00675 6.446 2.75711" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
5
schoolNewsWeb/public/img/admin/study.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.28 7.28125C14.3993 7.2286 14.5006 7.14209 14.5713 7.03245C14.642 6.9228 14.6789 6.79484 14.6776 6.6644C14.6762 6.53397 14.6366 6.40679 14.5637 6.29863C14.4908 6.19048 14.3877 6.10608 14.2673 6.05591L8.55334 3.45325C8.37963 3.37401 8.19093 3.33301 8 3.33301C7.80908 3.33301 7.62038 3.37401 7.44667 3.45325L1.73334 6.05325C1.61465 6.10523 1.51368 6.19067 1.44278 6.29912C1.37188 6.40758 1.33412 6.53434 1.33412 6.66391C1.33412 6.79348 1.37188 6.92025 1.44278 7.0287C1.51368 7.13716 1.61465 7.2226 1.73334 7.27458L7.44667 9.87991C7.62038 9.95915 7.80908 10.0002 8 10.0002C8.19093 10.0002 8.37963 9.95915 8.55334 9.87991L14.28 7.28125Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.6667 6.6665V10.6665" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 8.3335V10.6668C4 11.1973 4.42143 11.706 5.17157 12.081C5.92172 12.4561 6.93913 12.6668 8 12.6668C9.06087 12.6668 10.0783 12.4561 10.8284 12.081C11.5786 11.706 12 11.1973 12 10.6668V8.3335" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
schoolNewsWeb/public/img/admin/usermange.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 14V12.6667C10.6667 11.9594 10.3857 11.2811 9.88561 10.781C9.38552 10.281 8.70724 10 7.99999 10H3.99999C3.29275 10 2.61447 10.281 2.11438 10.781C1.61428 11.2811 1.33333 11.9594 1.33333 12.6667V14" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.6667 2.08545C11.2385 2.2337 11.7449 2.56763 12.1065 3.03482C12.468 3.50202 12.6641 4.07604 12.6641 4.66678C12.6641 5.25752 12.468 5.83154 12.1065 6.29874C11.7449 6.76594 11.2385 7.09987 10.6667 7.24812" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.6667 14.0002V12.6669C14.6662 12.0761 14.4696 11.5021 14.1076 11.0351C13.7456 10.5682 13.2388 10.2346 12.6667 10.0869" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.99999 7.33333C7.47275 7.33333 8.66666 6.13943 8.66666 4.66667C8.66666 3.19391 7.47275 2 5.99999 2C4.52724 2 3.33333 3.19391 3.33333 4.66667C3.33333 6.13943 4.52724 7.33333 5.99999 7.33333Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @description 轮播图相关API
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { Banner, Resource, ResultDomain } from '@/types';
|
||||
|
||||
/**
|
||||
* 轮播图API服务
|
||||
*/
|
||||
export const bannerApi = {
|
||||
/**
|
||||
* 获取轮播组件数据
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerList(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/homepage/banner/list');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 点击轮播跳转新闻详情
|
||||
* @param bannerID Banner ID
|
||||
* @returns Promise<ResultDomain<Resource>>
|
||||
*/
|
||||
async getBannerNewsDetail(bannerID: string): Promise<ResultDomain<Resource>> {
|
||||
const response = await api.get<Resource>(`/homepage/banner/click/${bannerID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃轮播列表
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getActiveBanners(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/homepage/banner/active');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
// 重新导出各个子模块
|
||||
export { bannerApi } from './banner';
|
||||
export { recommendApi } from './recommend';
|
||||
export { newsApi } from './news';
|
||||
export { menuApi } from './menu';
|
||||
|
||||
124
schoolNewsWeb/src/apis/resource/banner.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @description Banner 管理 API 接口
|
||||
* @filename banner-manage.ts
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-10-28
|
||||
*/
|
||||
|
||||
import { api } from '@/apis';
|
||||
import type { ResultDomain, Banner, PageParam } from '@/types';
|
||||
|
||||
/**
|
||||
* Banner 管理 API 服务
|
||||
*/
|
||||
export const bannerApi = {
|
||||
/**
|
||||
* 获取横幅列表
|
||||
* @param filter 筛选条件
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerList(filter?: Partial<Banner>): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/news/banners/list', filter);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取横幅分页列表
|
||||
* @param pageParam 分页参数
|
||||
* @param filter 筛选条件
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getBannerPage(pageParam: PageParam, filter?: Partial<Banner>): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.post<Banner>('/news/banners/banner/page', {
|
||||
pageParam,
|
||||
filter,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建横幅
|
||||
* @param banner 横幅信息
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async createBanner(banner: Banner): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.post<Banner>('/news/banners/banner', banner);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新横幅
|
||||
* @param banner 横幅信息
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async updateBanner(banner: Banner): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.put<Banner>('/news/banners/banner', banner);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除横幅
|
||||
* @param banner 横幅信息(包含 bannerID)
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteBanner(banner: Banner): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>('/news/banners/banner', {
|
||||
data: banner
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据 ID 删除横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteBannerById(bannerID: string): Promise<ResultDomain<boolean>> {
|
||||
return this.deleteBanner({ id: bannerID });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新横幅状态
|
||||
* @param bannerID 横幅ID
|
||||
* @param status 状态值(0禁用 1启用)
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async updateBannerStatus(bannerID: string, status: number): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.put<Banner>('/news/banners/banner/status', {
|
||||
id: bannerID,
|
||||
status,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 启用横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async enableBanner(bannerID: string): Promise<ResultDomain<Banner>> {
|
||||
return this.updateBannerStatus(bannerID, 1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 禁用横幅
|
||||
* @param bannerID 横幅ID
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async disableBanner(bannerID: string): Promise<ResultDomain<Banner>> {
|
||||
return this.updateBannerStatus(bannerID, 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取首页横幅列表
|
||||
* @returns Promise<ResultDomain<Banner>>
|
||||
*/
|
||||
async getHomeBannerList(): Promise<ResultDomain<Banner>> {
|
||||
const response = await api.get<Banner>('/news/banners/home');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default bannerApi;
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
|
||||
export * from './resourceCategory';
|
||||
export * from './resourceTag';
|
||||
export * from './resource';
|
||||
export * from './resource';
|
||||
export { bannerApi} from './banner';
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* @description 资源分类API接口
|
||||
* @filename resourceCategory.ts
|
||||
* @author yslg
|
||||
* @copyright xyzh
|
||||
* @since 2025-10-15
|
||||
*
|
||||
* ⚠️ 注意:此API已废弃!
|
||||
*
|
||||
* 从2025-10-27起,资源分类功能已迁移到标签系统(Tag)中。
|
||||
*
|
||||
* 迁移说明:
|
||||
* - 原 tb_resource_category 表已废弃
|
||||
* - 改为使用 tb_tag 表的 tag_type=1 表示文章分类标签
|
||||
* - 请使用 resourceTagApi.getTagsByType(1) 替代本 API 的方法
|
||||
*
|
||||
* API 迁移对照:
|
||||
* - getCategoryList() → resourceTagApi.getTagsByType(1)
|
||||
* - getCategoryById(id) → resourceTagApi.getTagById(id)
|
||||
* - createCategory(category) → resourceTagApi.createTag({...category, tagType: 1})
|
||||
* - updateCategory(category) → resourceTagApi.updateTag(category)
|
||||
* - deleteCategory(id) → resourceTagApi.deleteTag(id)
|
||||
*
|
||||
* @deprecated 请使用 resourceTagApi 代替
|
||||
*/
|
||||
|
||||
import { api } from '@/apis';
|
||||
import type { ResultDomain, ResourceCategory } from '@/types';
|
||||
|
||||
/**
|
||||
* 资源分类API服务
|
||||
* @deprecated 已废弃,请使用 resourceTagApi.getTagsByType(1) 获取文章分类标签
|
||||
*/
|
||||
export const resourceCategoryApi = {
|
||||
/**
|
||||
* 获取分类列表
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryList(): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>('/news/categorys/list');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据ID获取分类详情
|
||||
* @param tagID 分类ID
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryById(tagID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>(`/news/categorys/category/${tagID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
* @param category 分类信息
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async createCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.post<ResourceCategory>('/news/categorys/category', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
* @param category 分类信息
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async updateCategory(category: ResourceCategory): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.put<ResourceCategory>('/news/categorys/category', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
* @param tagID 分类ID
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteCategory(tagID: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>(`/news/categorys/category/${tagID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类状态
|
||||
* @param tagID 分类ID
|
||||
* @param status 状态值
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async updateCategoryStatus(tagID: string, status: number): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.put<ResourceCategory>(`/news/categorys/category/${tagID}/status`, null, {
|
||||
params: { status }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取分类树
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getCategoryTree(): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>('/news/categorys/tree');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取子分类
|
||||
* @param parentID 父分类ID
|
||||
* @returns Promise<ResultDomain<ResourceCategory>>
|
||||
*/
|
||||
async getChildCategories(parentID: string): Promise<ResultDomain<ResourceCategory>> {
|
||||
const response = await api.get<ResourceCategory>(`/news/categorys/category/${parentID}/children`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export default resourceCategoryApi;
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { Course, CourseChapter, ResultDomain,CourseVO,PageRequest, PageParam, CourseNode } from '@/types';
|
||||
import type { Course, CourseChapter, ResultDomain, CourseItemVO, PageParam, CourseNode } from '@/types';
|
||||
|
||||
/**
|
||||
* 课程API服务
|
||||
@@ -38,30 +38,40 @@ export const courseApi = {
|
||||
/**
|
||||
* 根据ID获取课程详情
|
||||
* @param courseID 课程ID
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async getCourseById(courseID: string): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.get<CourseVO>(`${this.prefixCourse}/${courseID}`);
|
||||
async getCourseById(courseID: string): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.get<CourseItemVO>(`${this.prefixCourse}/${courseID}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取课程学习进度
|
||||
* @param courseID 课程ID
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async getCourseProgress(courseID: string): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.get<CourseItemVO>(`${this.prefixCourse}/${courseID}/progress`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建课程
|
||||
* @param course 课程数据
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @param courseItemVO 课程数据
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async createCourse(course: CourseVO): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.post<CourseVO>(`${this.prefixCourse}/course`, course);
|
||||
async createCourse(courseItemVO: CourseItemVO): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.post<CourseItemVO>(`${this.prefixCourse}/course`, courseItemVO);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新课程
|
||||
* @param course 课程数据
|
||||
* @returns Promise<ResultDomain<Course>>
|
||||
* @param courseItemVO 课程数据
|
||||
* @returns Promise<ResultDomain<CourseItemVO>>
|
||||
*/
|
||||
async updateCourse(courseVO: CourseVO): Promise<ResultDomain<CourseVO>> {
|
||||
const response = await api.put<CourseVO>(`${this.prefixCourse}/course`, courseVO);
|
||||
async updateCourse(courseItemVO: CourseItemVO): Promise<ResultDomain<CourseItemVO>> {
|
||||
const response = await api.put<CourseItemVO>(`${this.prefixCourse}/course`, courseItemVO);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const learningTaskApi = {
|
||||
* @returns Promise<ResultDomain<boolean>>
|
||||
*/
|
||||
async deleteTask(taskID: string): Promise<ResultDomain<boolean>> {
|
||||
const response = await api.delete<boolean>(`${this.learningTaskPrefix}/task/${taskID}`);
|
||||
const response = await api.delete<boolean>(`${this.learningTaskPrefix}/task`, {taskID});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
4
schoolNewsWeb/src/assets/imgs/article.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3333 14.6666H2.66667C2.29848 14.6666 2 14.3681 2 13.9999V1.99992C2 1.63173 2.29848 1.33325 2.66667 1.33325H13.3333C13.7015 1.33325 14 1.63173 14 1.99992V13.9999C14 14.3681 13.7015 14.6666 13.3333 14.6666ZM12.6667 13.3333V2.66659H3.33333V13.3333H12.6667ZM4.66667 3.99992H7.33333V6.66658H4.66667V3.99992ZM4.66667 7.99992H11.3333V9.33325H4.66667V7.99992ZM4.66667 10.6666H11.3333V11.9999H4.66667V10.6666ZM8.66667 4.66658H11.3333V5.99992H8.66667V4.66658Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 590 B |
4
schoolNewsWeb/src/assets/imgs/book-read.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.50049 2.99505C1.50049 2.58357 1.84197 2.25 2.24434 2.25H15.7566C16.1675 2.25 16.5005 2.58371 16.5005 2.99505V15.0049C16.5005 15.4164 16.159 15.75 15.7566 15.75H2.24434C1.83353 15.75 1.50049 15.4163 1.50049 15.0049V2.99505ZM8.25049 3.75H3.00049V14.25H8.25049V3.75ZM9.75049 3.75V14.25H15.0005V3.75H9.75049ZM10.5005 5.25H14.2505V6.75H10.5005V5.25ZM10.5005 7.5H14.2505V9H10.5005V7.5Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 519 B |
4
schoolNewsWeb/src/assets/imgs/clock.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 16.5C4.85786 16.5 1.5 13.1421 1.5 9C1.5 4.85786 4.85786 1.5 9 1.5C13.1421 1.5 16.5 4.85786 16.5 9C16.5 13.1421 13.1421 16.5 9 16.5ZM9 15C12.3137 15 15 12.3137 15 9C15 5.68629 12.3137 3 9 3C5.68629 3 3 5.68629 3 9C3 12.3137 5.68629 15 9 15ZM9.75 9H12.75V10.5H8.25V5.25H9.75V9Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 415 B |
5
schoolNewsWeb/src/assets/imgs/edit.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 2H3.33333C2.97971 2 2.64057 2.14048 2.39052 2.39052C2.14048 2.64057 2 2.97971 2 3.33333V12.6667C2 13.0203 2.14048 13.3594 2.39052 13.6095C2.64057 13.8595 2.97971 14 3.33333 14H12.6667C13.0203 14 13.3594 13.8595 13.6095 13.6095C13.8595 13.3594 14 13.0203 14 12.6667V8" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.25 1.75015C12.5152 1.48493 12.8749 1.33594 13.25 1.33594C13.6251 1.33594 13.9848 1.48493 14.25 1.75015C14.5152 2.01537 14.6642 2.37508 14.6642 2.75015C14.6642 3.12522 14.5152 3.48493 14.25 3.75015L8.24136 9.75948C8.08305 9.91765 7.88749 10.0334 7.67269 10.0962L5.75735 10.6562C5.69999 10.6729 5.63918 10.6739 5.58129 10.6591C5.52341 10.6442 5.47057 10.6141 5.42832 10.5719C5.38607 10.5296 5.35595 10.4768 5.34112 10.4189C5.32629 10.361 5.32729 10.3002 5.34402 10.2428L5.90402 8.32748C5.96704 8.11285 6.08304 7.91752 6.24136 7.75948L12.25 1.75015Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
9
schoolNewsWeb/src/assets/imgs/eye.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="38" height="32" viewBox="0 0 38 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="path-1-inside-1_906_1872" fill="white">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H30C34.4183 0 38 3.58172 38 8V24C38 28.4183 34.4183 32 30 32H8C3.58172 32 0 28.4183 0 24V8Z"/>
|
||||
</mask>
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H30C34.4183 0 38 3.58172 38 8V24C38 28.4183 34.4183 32 30 32H8C3.58172 32 0 28.4183 0 24V8Z" fill="white"/>
|
||||
<path d="M8 0V1H30V0V-1H8V0ZM38 8H37V24H38H39V8H38ZM30 32V31H8V32V33H30V32ZM0 24H1V8H0H-1V24H0ZM8 32V31C4.13401 31 1 27.866 1 24H0H-1C-1 28.9706 3.02944 33 8 33V32ZM38 24H37C37 27.866 33.866 31 30 31V32V33C34.9706 33 39 28.9706 39 24H38ZM30 0V1C33.866 1 37 4.13401 37 8H38H39C39 3.02944 34.9706 -1 30 -1V0ZM8 0V-1C3.02944 -1 -1 3.02944 -1 8H0H1C1 4.13401 4.13401 1 8 1V0Z" fill="black" fill-opacity="0.1" mask="url(#path-1-inside-1_906_1872)"/>
|
||||
<path d="M19.0002 10C22.5949 10 25.5856 12.5865 26.2126 16C25.5856 19.4135 22.5949 22 19.0002 22C15.4054 22 12.4147 19.4135 11.7877 16C12.4147 12.5865 15.4054 10 19.0002 10ZM19.0002 20.6667C21.8239 20.6667 24.2402 18.7013 24.8518 16C24.2402 13.2987 21.8239 11.3333 19.0002 11.3333C16.1764 11.3333 13.7601 13.2987 13.1485 16C13.7601 18.7013 16.1764 20.6667 19.0002 20.6667ZM19.0002 19C17.3433 19 16.0001 17.6569 16.0001 16C16.0001 14.3431 17.3433 13 19.0002 13C20.657 13 22.0002 14.3431 22.0002 16C22.0002 17.6569 20.657 19 19.0002 19ZM19.0002 17.6667C19.9206 17.6667 20.6668 16.9205 20.6668 16C20.6668 15.0795 19.9206 14.3333 19.0002 14.3333C18.0797 14.3333 17.3335 15.0795 17.3335 16C17.3335 16.9205 18.0797 17.6667 19.0002 17.6667Z" fill="black"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
schoolNewsWeb/src/assets/imgs/plus.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.3335 8H12.6668" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.3335V12.6668" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 341 B |
5
schoolNewsWeb/src/assets/imgs/time-line.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="381" height="6" viewBox="0 0 381 6" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="381" height="6" rx="3" fill="#EAEAEA"/>
|
||||
<rect width="238.979" height="6" rx="3" fill="#EAEAEA"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 221 B |
8
schoolNewsWeb/src/assets/imgs/trashbin.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.66663 7.3335V11.3335" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.33337 7.3335V11.3335" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.6667 4V13.3333C12.6667 13.687 12.5262 14.0261 12.2762 14.2761C12.0261 14.5262 11.687 14.6667 11.3334 14.6667H4.66671C4.31309 14.6667 3.97395 14.5262 3.7239 14.2761C3.47385 14.0261 3.33337 13.687 3.33337 13.3333V4" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 4H14" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.33337 4.00016V2.66683C5.33337 2.31321 5.47385 1.97407 5.7239 1.72402C5.97395 1.47397 6.31309 1.3335 6.66671 1.3335H9.33337C9.687 1.3335 10.0261 1.47397 10.2762 1.72402C10.5262 1.97407 10.6667 2.31321 10.6667 2.66683V4.00016" stroke="#E7000B" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
4
schoolNewsWeb/src/assets/imgs/video.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3333 14.6666H2.66667C2.29848 14.6666 2 14.3681 2 13.9999V1.99992C2 1.63173 2.29848 1.33325 2.66667 1.33325H13.3333C13.7015 1.33325 14 1.63173 14 1.99992V13.9999C14 14.3681 13.7015 14.6666 13.3333 14.6666ZM12.6667 13.3333V2.66659H3.33333V13.3333H12.6667ZM4.66667 3.99992H7.33333V6.66658H4.66667V3.99992ZM4.66667 7.99992H11.3333V9.33325H4.66667V7.99992ZM4.66667 10.6666H11.3333V11.9999H4.66667V10.6666ZM8.66667 4.66658H11.3333V5.99992H8.66667V4.66658Z" fill="#4E5969"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 590 B |
@@ -8,7 +8,12 @@
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<div class="menu-item-inner">
|
||||
<i class="menu-icon" :class="menu.icon || 'icon-folder'"></i>
|
||||
<img
|
||||
v-if="menu.icon"
|
||||
:src="String(PUBLIC_IMG_PATH + '/' + menu.icon)"
|
||||
class="tab-icon"
|
||||
:alt="String(menu.name || '')"
|
||||
/>
|
||||
<span class="menu-title">{{ menu.name }}</span>
|
||||
<img
|
||||
src="@/assets/imgs/arrow-down.svg"
|
||||
@@ -44,7 +49,12 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="menu-item-inner">
|
||||
<i class="menu-icon" :class="menu.icon || 'icon-file'"></i>
|
||||
<img
|
||||
v-if="menu.icon"
|
||||
:src="String(PUBLIC_IMG_PATH + '/' + menu.icon)"
|
||||
class="tab-icon"
|
||||
:alt="String(menu.name || '')"
|
||||
/>
|
||||
<span class="menu-title">{{ menu.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,7 +67,7 @@ import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { SysMenu } from '@/types';
|
||||
import { MenuType } from '@/types/enums';
|
||||
|
||||
import { PUBLIC_IMG_PATH} from '@/config'
|
||||
// 递归组件需要声明名称(Vue 3.5+)
|
||||
defineOptions({
|
||||
name: 'MenuItem'
|
||||
@@ -153,15 +163,12 @@ function handleClick() {
|
||||
line-height: 1.43;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
.tab-icon {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
height: 16px;
|
||||
margin-right: 12px;
|
||||
|
||||
.collapsed & {
|
||||
margin-right: 0;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
@@ -211,12 +218,4 @@ function handleClick() {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
/* 图标字体类(简单实现,实际项目中使用图标库) */
|
||||
.icon-folder::before { content: "📁"; }
|
||||
.icon-file::before { content: "📄"; }
|
||||
.icon-dashboard::before { content: "📊"; }
|
||||
.icon-user::before { content: "👤"; }
|
||||
.icon-news::before { content: "📰"; }
|
||||
.icon-settings::before { content: "⚙️"; }
|
||||
</style>
|
||||
|
||||
@@ -179,13 +179,10 @@ function handleMenuClick(menu: SysMenu) {
|
||||
flex: 1;
|
||||
background: #F9FAFB;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
|
||||
// 使用 margin 而不是 padding,避免影响滚动高度计算
|
||||
// margin 不计入元素尺寸,所以不会导致滚动条
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
/* min-width: 0; */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
|
||||
// 美化滚动条
|
||||
&::-webkit-scrollbar {
|
||||
@@ -215,11 +212,8 @@ function handleMenuClick(menu: SysMenu) {
|
||||
background: #F9FAFB;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
// 使用 margin 而不是 padding,避免影响滚动高度计算
|
||||
> * {
|
||||
margin: 20px;
|
||||
}
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 美化滚动条
|
||||
&::-webkit-scrollbar {
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface Resource extends BaseDTO {
|
||||
* Banner实体
|
||||
*/
|
||||
export interface Banner extends BaseDTO {
|
||||
bannerID?: string;
|
||||
/** Banner标题 */
|
||||
title?: string;
|
||||
/** Banner图片URL */
|
||||
@@ -138,10 +139,8 @@ export interface Tag extends BaseDTO {
|
||||
color?: string;
|
||||
/** 标签类型(1-文章分类标签 2-课程分类标签 3-学习任务分类标签) */
|
||||
tagType?: number;
|
||||
/** 排序号 */
|
||||
orderNum?: number;
|
||||
/** 状态(0禁用 1启用) */
|
||||
status?: number;
|
||||
/** 使用计数(被标记的资源数量) */
|
||||
usageCount?: number;
|
||||
/** 创建者 */
|
||||
creator?: string;
|
||||
/** 更新者 */
|
||||
|
||||
@@ -118,15 +118,72 @@ export interface CourseNode extends BaseDTO {
|
||||
updater?: string;
|
||||
}
|
||||
|
||||
export interface ChapterVO extends BaseDTO {
|
||||
chapter: CourseChapter;
|
||||
nodes: CourseNode[];
|
||||
}
|
||||
/**
|
||||
* 课程项目VO - 统一的课程视图对象
|
||||
* 包含课程、章节、学习节点、学习记录的字段
|
||||
*/
|
||||
export interface CourseItemVO extends BaseDTO {
|
||||
// ========== 课程基本信息 ==========
|
||||
/** 课程ID */
|
||||
courseID?: string;
|
||||
/** 课程名称 */
|
||||
name?: string;
|
||||
/** 课程封面图片 */
|
||||
coverImage?: string;
|
||||
/** 课程描述 */
|
||||
description?: string;
|
||||
/** 课程时长(分钟) */
|
||||
duration?: number;
|
||||
/** 授课老师 */
|
||||
teacher?: string;
|
||||
/** 课程状态(0未上线 1已上线 2已下架) */
|
||||
status?: number;
|
||||
/** 浏览次数 */
|
||||
viewCount?: number;
|
||||
/** 学习人数 */
|
||||
learnCount?: number;
|
||||
/** 课程创建时间 */
|
||||
createTime?: string;
|
||||
|
||||
export interface CourseVO extends BaseDTO {
|
||||
course: Course;
|
||||
courseChapters: ChapterVO[];
|
||||
courseTags: CourseTag[];
|
||||
// ========== 学习记录信息 ==========
|
||||
/** 学习记录ID */
|
||||
recordID?: string;
|
||||
/** 学习进度(0-100) */
|
||||
progress?: number;
|
||||
/** 是否完成 */
|
||||
isComplete?: boolean;
|
||||
/** 学习时长(秒) */
|
||||
learningDuration?: number;
|
||||
/** 最后学习时间 */
|
||||
lastLearnTime?: string;
|
||||
/** 完成时间 */
|
||||
completeTime?: string;
|
||||
|
||||
// ========== 章节节点信息 ==========
|
||||
/** 章节ID(当此对象代表章节或节点时使用) */
|
||||
chapterID?: string;
|
||||
/** 节点ID(当此对象代表节点时使用) */
|
||||
nodeID?: string;
|
||||
/** 父级ID(章节的父章节ID) */
|
||||
parentID?: string;
|
||||
/** 节点类型(1视频 2文档 3音频 4图片 5链接) */
|
||||
nodeType?: number;
|
||||
/** 节点内容(富文本内容) */
|
||||
content?: string;
|
||||
/** 视频URL */
|
||||
videoUrl?: string;
|
||||
/** 资源ID */
|
||||
resourceID?: string;
|
||||
/** 排序号 */
|
||||
orderNum?: number;
|
||||
/** 是否必修(1必修 0选修) */
|
||||
isRequired?: number;
|
||||
|
||||
// ========== 层级结构 ==========
|
||||
/** 章节列表(课程的章节列表,或章节的节点列表) */
|
||||
chapters?: CourseItemVO[];
|
||||
/** 章节节点映射(key: chapterID, value: 该章节下的节点列表) */
|
||||
chapterNodes?: Record<string, CourseItemVO[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RouteRecordRaw, RouteLocationNormalized } from 'vue-router';
|
||||
export function getParentChildrenRoutes(route: RouteLocationNormalized): RouteRecordRaw[] {
|
||||
// 判断是否有父节点(至少需要2个匹配的路由)
|
||||
if (route.matched.length < 2) {
|
||||
console.log('没有父节点,route.matched 长度不足');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -12,7 +11,6 @@ export function getParentChildrenRoutes(route: RouteLocationNormalized): RouteRe
|
||||
|
||||
// 检查父路由是否有子路由
|
||||
if (!parentRoute?.children || parentRoute.children.length === 0) {
|
||||
console.log('父路由没有子路由');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
172
schoolNewsWeb/src/views/admin/AdminLayout.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 头部区域:主标题和副标题 -->
|
||||
<div class="admin-layout-header">
|
||||
<div class="header-content">
|
||||
<h1 class="main-title">{{ title }}</h1>
|
||||
<p class="subtitle" v-if="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页导航:当前路由的兄弟路由 -->
|
||||
<div class="admin-layout-tabs" v-if="menus.length > 0">
|
||||
<div class="tab-item-container">
|
||||
<router-link
|
||||
v-for="menu in menus"
|
||||
:key="menu.path"
|
||||
:to="getFullPath(menu)"
|
||||
class="tab-item"
|
||||
:class="{ active: isActive(menu) }"
|
||||
>
|
||||
<img
|
||||
v-if="menu.meta?.icon"
|
||||
:src="String(PUBLIC_IMG_PATH + '/' + menu.meta.icon)"
|
||||
class="tab-icon"
|
||||
:alt="String(menu.meta?.title || '')"
|
||||
/>
|
||||
<span class="tab-text">{{ menu.meta?.title }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="admin-layout-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, RouteRecordRaw } from 'vue-router';
|
||||
import { getParentChildrenRoutes } from '@/utils/routeUtils';
|
||||
import { PUBLIC_IMG_PATH} from '@/config'
|
||||
|
||||
// 定义 props
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// 获取兄弟路由(计算属性,确保响应式更新)
|
||||
const menus = computed(() => {
|
||||
return getParentChildrenRoutes(route);
|
||||
});
|
||||
|
||||
// 获取完整路径
|
||||
function getFullPath(menu: RouteRecordRaw): string {
|
||||
// 如果路径是相对路径,需要拼接父路径
|
||||
if (menu.path?.startsWith('/')) {
|
||||
return menu.path;
|
||||
}
|
||||
|
||||
// 获取父路径
|
||||
const parentPath = route.matched.length >= 2
|
||||
? route.matched[route.matched.length - 2].path
|
||||
: '';
|
||||
|
||||
return `${parentPath}/${menu.path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
// 判断是否为当前激活路由
|
||||
function isActive(menu: RouteRecordRaw): boolean {
|
||||
const fullPath = getFullPath(menu);
|
||||
return route.path === fullPath || route.path.startsWith(fullPath + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-layout-header {
|
||||
padding: 4px 0 24px 0;
|
||||
|
||||
.header-content {
|
||||
.main-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 28px;
|
||||
color: #1D2129;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 22px;
|
||||
color: #86909C;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-layout-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.tab-item-container {
|
||||
display: flex;
|
||||
border-radius: 15px;
|
||||
gap: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #C9CDD4;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #FFFFFF;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
font-size: 14px;
|
||||
color: #4E5969;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
.tab-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #165DFF;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #165DFF;
|
||||
font-weight: 500;
|
||||
border-bottom-color: #165DFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-layout-content {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
1
schoolNewsWeb/src/views/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AdminLayout } from './AdminLayout.vue';
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="achievement-management">
|
||||
<div class="header">
|
||||
<h2>成就管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增成就
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="成就管理"
|
||||
subtitle="管理用户成就、徽章等激励内容"
|
||||
>
|
||||
<div class="achievement-management">
|
||||
<div class="header">
|
||||
<h2>成就管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增成就
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="filter-bar">
|
||||
@@ -305,7 +309,8 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -316,6 +321,11 @@ import { achievementApi } from '@/apis/achievement';
|
||||
import type { Achievement, UserAchievement } from '@/types';
|
||||
import { AchievementEnumHelper } from '@/types/enums/achievement-enums';
|
||||
import { getAchievementIconUrl } from '@/utils/iconUtils';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'AchievementManagementView'
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
@@ -544,7 +554,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.achievement-management {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -1,75 +1,80 @@
|
||||
<template>
|
||||
<div class="ai-config">
|
||||
<el-form :model="configForm" label-width="150px" class="config-form">
|
||||
<el-divider content-position="left">模型配置</el-divider>
|
||||
|
||||
<el-form-item label="AI模型">
|
||||
<el-select v-model="configForm.model" placeholder="选择AI模型">
|
||||
<el-option label="GPT-3.5" value="gpt-3.5" />
|
||||
<el-option label="GPT-4" value="gpt-4" />
|
||||
<el-option label="Claude" value="claude" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<AdminLayout title="AI配置" subtitle="AI配置">
|
||||
<div class="ai-config">
|
||||
<el-form :model="configForm" label-width="150px" class="config-form">
|
||||
<el-divider content-position="left">模型配置</el-divider>
|
||||
|
||||
<el-form-item label="AI模型">
|
||||
<el-select v-model="configForm.model" placeholder="选择AI模型">
|
||||
<el-option label="GPT-3.5" value="gpt-3.5" />
|
||||
<el-option label="GPT-4" value="gpt-4" />
|
||||
<el-option label="Claude" value="claude" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="configForm.apiKey" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="configForm.apiKey" type="password" show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="configForm.apiUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API地址">
|
||||
<el-input v-model="configForm.apiUrl" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">对话配置</el-divider>
|
||||
<el-divider content-position="left">对话配置</el-divider>
|
||||
|
||||
<el-form-item label="温度值">
|
||||
<el-slider v-model="configForm.temperature" :min="0" :max="2" :step="0.1" show-input />
|
||||
<span class="help-text">控制回答的随机性,值越大回答越随机</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="温度值">
|
||||
<el-slider v-model="configForm.temperature" :min="0" :max="2" :step="0.1" show-input />
|
||||
<span class="help-text">控制回答的随机性,值越大回答越随机</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大token数">
|
||||
<el-input-number v-model="configForm.maxTokens" :min="100" :max="4000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大token数">
|
||||
<el-input-number v-model="configForm.maxTokens" :min="100" :max="4000" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="历史对话轮数">
|
||||
<el-input-number v-model="configForm.historyTurns" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="历史对话轮数">
|
||||
<el-input-number v-model="configForm.historyTurns" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">功能配置</el-divider>
|
||||
<el-divider content-position="left">功能配置</el-divider>
|
||||
|
||||
<el-form-item label="启用流式输出">
|
||||
<el-switch v-model="configForm.enableStreaming" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用流式输出">
|
||||
<el-switch v-model="configForm.enableStreaming" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用文件解读">
|
||||
<el-switch v-model="configForm.enableFileInterpretation" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用文件解读">
|
||||
<el-switch v-model="configForm.enableFileInterpretation" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用知识库检索">
|
||||
<el-switch v-model="configForm.enableKnowledgeRetrieval" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用知识库检索">
|
||||
<el-switch v-model="configForm.enableKnowledgeRetrieval" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="系统提示词">
|
||||
<el-input
|
||||
v-model="configForm.systemPrompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="设置AI助手的角色和行为..."
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统提示词">
|
||||
<el-input
|
||||
v-model="configForm.systemPrompt"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="设置AI助手的角色和行为..."
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存配置</el-button>
|
||||
<el-button @click="handleTest">测试连接</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存配置</el-button>
|
||||
<el-button @click="handleTest">测试连接</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElForm, ElFormItem, ElSelect, ElOption, ElInput, ElSlider, ElInputNumber, ElSwitch, ElButton, ElDivider, ElMessage } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'AIConfigView'
|
||||
});
|
||||
const configForm = ref({
|
||||
model: 'gpt-3.5',
|
||||
apiKey: '',
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="ai-management">
|
||||
<h1 class="page-title">智能体管理</h1>
|
||||
<AdminLayout title="智能体管理" subtitle="智能体管理">
|
||||
<div class="ai-management">
|
||||
<h1 class="page-title">智能体管理</h1>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基础配置" name="config">
|
||||
<AIConfig />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="知识库管理" name="knowledge">
|
||||
<KnowledgeManagement />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="基础配置" name="config">
|
||||
<AIConfig />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="知识库管理" name="knowledge">
|
||||
<KnowledgeManagement />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -18,7 +20,10 @@ import { ref } from 'vue';
|
||||
import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import AIConfig from './components/AIConfig.vue';
|
||||
import KnowledgeManagement from './components/KnowledgeManagement.vue';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'AIManagementView'
|
||||
});
|
||||
const activeTab = ref('config');
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
<template>
|
||||
<div class="knowledge-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增知识</el-button>
|
||||
<el-button @click="handleImport">批量导入</el-button>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索知识..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
<AdminLayout title="知识库管理" subtitle="知识库管理">
|
||||
<div class="knowledge-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增知识</el-button>
|
||||
<el-button @click="handleImport">批量导入</el-button>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索知识..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="knowledgeList" style="width: 100%">
|
||||
<el-table-column prop="title" label="标题" min-width="200" />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="tags" label="标签" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="tag in row.tags" :key="tag" size="small" style="margin-right: 4px;">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
@change="toggleStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateDate" label="更新时间" width="150" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="knowledgeList" style="width: 100%">
|
||||
<el-table-column prop="title" label="标题" min-width="200" />
|
||||
<el-table-column prop="category" label="分类" width="120" />
|
||||
<el-table-column prop="tags" label="标签" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="tag in row.tags" :key="tag" size="small" style="margin-right: 4px;">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
@change="toggleStatus(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updateDate" label="更新时间" width="150" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editKnowledge(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteKnowledge(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElSwitch, ElPagination, ElMessage } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'KnowledgeManagementView'
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
<template>
|
||||
<div class="column-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增栏目</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="内容管理"
|
||||
subtitle="管理网站横幅、栏目、标签等内容信息"
|
||||
>
|
||||
<div class="column-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增栏目</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="columns" style="width: 100%" row-key="id" :tree-props="{children: 'children'}">
|
||||
<el-table-column prop="name" label="栏目名称" min-width="200" />
|
||||
<el-table-column prop="code" label="栏目编码" width="150" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status ? 'success' : 'info'">
|
||||
{{ row.status ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editColumn(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteColumn(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<el-table :data="columns" style="width: 100%" row-key="id" :tree-props="{children: 'children'}">
|
||||
<el-table-column prop="name" label="栏目名称" min-width="200" />
|
||||
<el-table-column prop="code" label="栏目编码" width="150" />
|
||||
<el-table-column prop="sort" label="排序" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status ? 'success' : 'info'">
|
||||
{{ row.status ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="editColumn(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteColumn(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElButton, ElTable, ElTableColumn, ElTag, ElMessage } from 'element-plus';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'ColumnManagementView'
|
||||
});
|
||||
|
||||
const columns = ref<any[]>([]);
|
||||
|
||||
@@ -55,11 +65,12 @@ function deleteColumn(row: any) {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.column-management {
|
||||
background: #FFFFFF;
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
<template>
|
||||
<div class="language-management">
|
||||
<h1 class="page-title">语言管理</h1>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="Banner管理" name="banner">
|
||||
<BannerManagement />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="资源栏目管理" name="column">
|
||||
<ColumnManagement />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="标签管理" name="tag">
|
||||
<TagManagement />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="内容管理"
|
||||
subtitle="管理网站横幅、栏目、标签等内容信息"
|
||||
>
|
||||
<div class="content-management">
|
||||
<el-empty description="请使用顶部标签页切换到Banner管理、标签管理或栏目管理" />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import BannerManagement from './components/BannerManagement.vue';
|
||||
import ColumnManagement from './components/ColumnManagement.vue';
|
||||
import TagManagement from './components/TagManagement.vue';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
const activeTab = ref('banner');
|
||||
defineOptions({
|
||||
name: 'ContentManagementView'
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.language-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 24px;
|
||||
.content-management {
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,53 +1,49 @@
|
||||
<template>
|
||||
<div class="tag-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增标签</el-button>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索标签..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="标签管理"
|
||||
subtitle="各类标签信息"
|
||||
>
|
||||
<div class="tag-management">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="header">
|
||||
<h2 class="title">数据标签管理</h2>
|
||||
<button class="add-button" @click="showCreateDialog">
|
||||
<img src="@/assets/imgs/plus.svg" alt="添加" />
|
||||
<span>添加标签</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredTags" style="width: 100%" v-loading="loading">
|
||||
<el-table-column prop="name" label="标签名称" min-width="150" />
|
||||
<el-table-column prop="tagType" label="标签类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTagTypeColor(row.tagType)">
|
||||
{{ getTagTypeName(row.tagType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="color" label="颜色" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="color-display">
|
||||
<div class="color-preview" :style="{ background: row.color }"></div>
|
||||
<span>{{ row.color }}</span>
|
||||
<!-- 标签卡片网格 -->
|
||||
<div v-loading="loading" class="tag-grid">
|
||||
<div
|
||||
v-for="tag in tags"
|
||||
:key="tag.tagID"
|
||||
class="tag-card"
|
||||
>
|
||||
<div class="tag-content">
|
||||
<div class="tag-info">
|
||||
<div class="tag-dot" :style="{ background: tag.color }"></div>
|
||||
<span class="tag-name">{{ tag.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orderNum" label="排序" width="80" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.createTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="editTag(row)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="deleteTag(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- <div class="tag-badge">{{ tag.usageCount || 0 }}</div> -->
|
||||
</div>
|
||||
|
||||
<div class="tag-actions">
|
||||
<button class="edit-button" @click="editTag(tag)">
|
||||
<img src="@/assets/imgs/edit.svg" alt="编辑" />
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
<button class="delete-button" @click="deleteTag(tag)">
|
||||
<img src="@/assets/imgs/trashbin.svg" alt="删除" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && tags.length === 0" class="empty-state">
|
||||
暂无标签数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建/编辑标签对话框 -->
|
||||
<el-dialog
|
||||
@@ -84,17 +80,6 @@
|
||||
placeholder="请输入标签描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序号" prop="orderNum">
|
||||
<el-input-number v-model="currentTag.orderNum" :min="0" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="currentTag.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -102,16 +87,21 @@
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { resourceTagApi } from '@/apis/resource';
|
||||
import type { Tag } from '@/types/resource';
|
||||
import {AdminLayout} from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'TagManagementView'
|
||||
});
|
||||
|
||||
const searchKeyword = ref('');
|
||||
const tags = ref<Tag[]>([]);
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
@@ -123,9 +113,7 @@ const currentTag = ref<Partial<Tag>>({
|
||||
name: '',
|
||||
tagType: 1,
|
||||
color: '#409EFF',
|
||||
description: '',
|
||||
orderNum: 0,
|
||||
status: 1
|
||||
description: ''
|
||||
});
|
||||
|
||||
const rules: FormRules = {
|
||||
@@ -140,16 +128,6 @@ const rules: FormRules = {
|
||||
]
|
||||
};
|
||||
|
||||
// 过滤后的标签列表
|
||||
const filteredTags = computed(() => {
|
||||
if (!searchKeyword.value) return tags.value;
|
||||
const keyword = searchKeyword.value.toLowerCase();
|
||||
return tags.value.filter(tag =>
|
||||
tag.name?.toLowerCase().includes(keyword) ||
|
||||
tag.description?.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadTags();
|
||||
});
|
||||
@@ -161,6 +139,8 @@ async function loadTags() {
|
||||
const result = await resourceTagApi.getTagList();
|
||||
if (result.success) {
|
||||
tags.value = result.dataList || [];
|
||||
// TODO: 加载每个标签的使用计数
|
||||
// 这里可以调用 API 获取每个标签被使用的资源数量
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载标签列表失败');
|
||||
}
|
||||
@@ -172,11 +152,6 @@ async function loadTags() {
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
function handleSearch() {
|
||||
// 搜索由computed自动处理
|
||||
}
|
||||
|
||||
// 显示创建对话框
|
||||
function showCreateDialog() {
|
||||
isEdit.value = false;
|
||||
@@ -184,9 +159,7 @@ function showCreateDialog() {
|
||||
name: '',
|
||||
tagType: 1,
|
||||
color: '#409EFF',
|
||||
description: '',
|
||||
orderNum: 0,
|
||||
status: 1
|
||||
description: ''
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
@@ -259,61 +232,208 @@ async function handleSubmit() {
|
||||
function handleDialogClose() {
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
// 获取标签类型名称
|
||||
function getTagTypeName(type?: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: '文章分类',
|
||||
2: '课程分类',
|
||||
3: '任务分类'
|
||||
};
|
||||
return map[type || 1] || '未知';
|
||||
}
|
||||
|
||||
// 获取标签类型颜色
|
||||
function getTagTypeColor(type?: number): string {
|
||||
const map: Record<number, string> = {
|
||||
1: 'primary',
|
||||
2: 'success',
|
||||
3: 'warning'
|
||||
};
|
||||
return map[type || 1] || '';
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(time?: string): string {
|
||||
if (!time) return '-';
|
||||
return new Date(time).toLocaleString('zh-CN');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tag-management {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-display {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 1.5em;
|
||||
letter-spacing: -0.02em;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: #E7000B;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #FFFFFF;
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.43em;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #c50009;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #a80008;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.tag-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
padding: 16px;
|
||||
background: #F9FAFB;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tag-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0e0e0;
|
||||
.tag-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 1.5em;
|
||||
letter-spacing: -0.02em;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
background: #ECEEF2;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 1.33em;
|
||||
color: #030213;
|
||||
}
|
||||
|
||||
.tag-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit-button {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.43em;
|
||||
letter-spacing: -0.01em;
|
||||
color: #0A0A0A;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 32px;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #fff5f5;
|
||||
border-color: rgba(231, 0, 11, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 响应式调整
|
||||
@media (max-width: 1400px) {
|
||||
.tag-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tag-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,218 +1,220 @@
|
||||
<template>
|
||||
<div class="log-management">
|
||||
<div class="header">
|
||||
<h2>执行日志</h2>
|
||||
<el-button type="danger" @click="handleCleanLogs">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清理日志
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务名称</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务分组</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskGroup"
|
||||
placeholder="请输入任务分组"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">执行状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.executeStatus"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="成功" :value="1" />
|
||||
<el-option label="失败" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
<AdminLayout title="执行日志" subtitle="执行日志管理">
|
||||
<div class="log-management">
|
||||
<div class="header">
|
||||
<h2>执行日志</h2>
|
||||
<el-button type="danger" @click="handleCleanLogs">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清理日志
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table
|
||||
:data="logList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="150" />
|
||||
<el-table-column prop="taskGroup" label="任务分组" width="120" />
|
||||
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
|
||||
<el-table-column prop="methodName" label="方法名称" width="120" />
|
||||
<el-table-column label="执行状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.executeStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.executeStatus === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="executeDuration" label="执行时长" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.executeDuration }}ms
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleViewDetail(row)"
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务名称</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务分组</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskGroup"
|
||||
placeholder="请输入任务分组"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">执行状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.executeStatus"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
查看详情
|
||||
<el-option label="成功" :value="1" />
|
||||
<el-option label="失败" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<el-table
|
||||
:data="logList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="150" />
|
||||
<el-table-column prop="taskGroup" label="任务分组" width="120" />
|
||||
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
|
||||
<el-table-column prop="methodName" label="方法名称" width="120" />
|
||||
<el-table-column label="执行状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.executeStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.executeStatus === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="executeDuration" label="执行时长" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.executeDuration }}ms
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startTime" label="开始时间" width="180" />
|
||||
<el-table-column prop="endTime" label="结束时间" width="180" />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleViewDetail(row)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="执行日志详情"
|
||||
width="700px"
|
||||
>
|
||||
<div class="detail-content" v-if="currentLog">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务名称:</span>
|
||||
<span class="detail-value">{{ currentLog.taskName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务分组:</span>
|
||||
<span class="detail-value">{{ currentLog.taskGroup }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Bean名称:</span>
|
||||
<span class="detail-value">{{ currentLog.beanName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">方法名称:</span>
|
||||
<span class="detail-value">{{ currentLog.methodName }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.methodParams">
|
||||
<span class="detail-label">方法参数:</span>
|
||||
<span class="detail-value">{{ currentLog.methodParams }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行状态:</span>
|
||||
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行时长:</span>
|
||||
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">开始时间:</span>
|
||||
<span class="detail-value">{{ currentLog.startTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">结束时间:</span>
|
||||
<span class="detail-value">{{ currentLog.endTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.executeMessage">
|
||||
<span class="detail-label">执行结果:</span>
|
||||
<div class="detail-message">{{ currentLog.executeMessage }}</div>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.exceptionInfo">
|
||||
<span class="detail-label">异常信息:</span>
|
||||
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清理日志对话框 -->
|
||||
<el-dialog
|
||||
v-model="cleanDialogVisible"
|
||||
title="清理日志"
|
||||
width="400px"
|
||||
>
|
||||
<div class="clean-dialog-content">
|
||||
<el-alert
|
||||
title="清理操作不可恢复,请谨慎操作!"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="clean-item">
|
||||
<span class="clean-label">保留天数:</span>
|
||||
<el-input-number
|
||||
v-model="cleanDays"
|
||||
:min="1"
|
||||
:max="365"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="clean-tip">天</span>
|
||||
</div>
|
||||
<div class="clean-desc">
|
||||
将删除 {{ cleanDays }} 天前的所有执行日志
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="cleanDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
@click="handleConfirmClean"
|
||||
:loading="submitting"
|
||||
>
|
||||
删除
|
||||
确定清理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="执行日志详情"
|
||||
width="700px"
|
||||
>
|
||||
<div class="detail-content" v-if="currentLog">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务名称:</span>
|
||||
<span class="detail-value">{{ currentLog.taskName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务分组:</span>
|
||||
<span class="detail-value">{{ currentLog.taskGroup }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Bean名称:</span>
|
||||
<span class="detail-value">{{ currentLog.beanName }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">方法名称:</span>
|
||||
<span class="detail-value">{{ currentLog.methodName }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.methodParams">
|
||||
<span class="detail-label">方法参数:</span>
|
||||
<span class="detail-value">{{ currentLog.methodParams }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行状态:</span>
|
||||
<el-tag :type="currentLog.executeStatus === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ currentLog.executeStatus === 1 ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行时长:</span>
|
||||
<span class="detail-value">{{ currentLog.executeDuration }}ms</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">开始时间:</span>
|
||||
<span class="detail-value">{{ currentLog.startTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">结束时间:</span>
|
||||
<span class="detail-value">{{ currentLog.endTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.executeMessage">
|
||||
<span class="detail-label">执行结果:</span>
|
||||
<div class="detail-message">{{ currentLog.executeMessage }}</div>
|
||||
</div>
|
||||
<div class="detail-item" v-if="currentLog.exceptionInfo">
|
||||
<span class="detail-label">异常信息:</span>
|
||||
<div class="detail-exception">{{ currentLog.exceptionInfo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 清理日志对话框 -->
|
||||
<el-dialog
|
||||
v-model="cleanDialogVisible"
|
||||
title="清理日志"
|
||||
width="400px"
|
||||
>
|
||||
<div class="clean-dialog-content">
|
||||
<el-alert
|
||||
title="清理操作不可恢复,请谨慎操作!"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<div class="clean-item">
|
||||
<span class="clean-label">保留天数:</span>
|
||||
<el-input-number
|
||||
v-model="cleanDays"
|
||||
:min="1"
|
||||
:max="365"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="clean-tip">天</span>
|
||||
</div>
|
||||
<div class="clean-desc">
|
||||
将删除 {{ cleanDays }} 天前的所有执行日志
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="cleanDialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleConfirmClean"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定清理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -221,7 +223,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Delete, Search, Refresh } from '@element-plus/icons-vue';
|
||||
import { crontabApi } from '@/apis/crontab';
|
||||
import type { CrontabLog, PageParam } from '@/types';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'LogManagementView'
|
||||
});
|
||||
// 数据状态
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -1,266 +1,268 @@
|
||||
<template>
|
||||
<div class="news-crawler">
|
||||
<div class="header">
|
||||
<h2>新闻爬虫配置</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增爬虫
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout title="新闻爬虫" subtitle="新闻爬虫配置">
|
||||
<div class="news-crawler">
|
||||
<div class="header">
|
||||
<h2>新闻爬虫配置</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增爬虫
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 说明卡片 -->
|
||||
<el-alert
|
||||
title="新闻爬虫说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<p>新闻爬虫配置允许系统自动从指定的新闻源抓取最新新闻内容。</p>
|
||||
<p>配置完成后,系统会按照设定的Cron表达式定时执行爬取任务。</p>
|
||||
</el-alert>
|
||||
<!-- 说明卡片 -->
|
||||
<el-alert
|
||||
title="新闻爬虫说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px"
|
||||
>
|
||||
<p>新闻爬虫配置允许系统自动从指定的新闻源抓取最新新闻内容。</p>
|
||||
<p>配置完成后,系统会按照设定的Cron表达式定时执行爬取任务。</p>
|
||||
</el-alert>
|
||||
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">爬虫名称</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入爬虫名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">爬虫名称</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入爬虫名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="运行中" :value="1" />
|
||||
<el-option label="已暂停" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 爬虫配置列表 -->
|
||||
<div class="crawler-list">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="crawler in crawlerList" :key="crawler.taskId">
|
||||
<el-card class="crawler-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<el-icon class="title-icon"><DocumentCopy /></el-icon>
|
||||
<span>{{ crawler.taskName }}</span>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="crawler.status === 1 ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ crawler.status === 1 ? '运行中' : '已暂停' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Bean名称:</span>
|
||||
<span class="info-value">{{ crawler.beanName }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">方法名称:</span>
|
||||
<span class="info-value">{{ crawler.methodName }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">执行周期:</span>
|
||||
<span class="info-value">{{ crawler.cronExpression }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="crawler.description">
|
||||
<span class="info-label">描述:</span>
|
||||
<span class="info-value">{{ crawler.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
v-if="crawler.status === 0"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleStart(crawler)"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
启动
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handlePause(crawler)"
|
||||
>
|
||||
<el-icon><VideoPause /></el-icon>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleExecute(crawler)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
执行一次
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(crawler)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(crawler)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-if="!loading && crawlerList.length === 0"
|
||||
description="暂无爬虫配置"
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[9, 18, 36]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="运行中" :value="1" />
|
||||
<el-option label="已暂停" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 爬虫配置列表 -->
|
||||
<div class="crawler-list">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8" v-for="crawler in crawlerList" :key="crawler.taskId">
|
||||
<el-card class="crawler-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<el-icon class="title-icon"><DocumentCopy /></el-icon>
|
||||
<span>{{ crawler.taskName }}</span>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="crawler.status === 1 ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ crawler.status === 1 ? '运行中' : '已暂停' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Bean名称:</span>
|
||||
<span class="info-value">{{ crawler.beanName }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">方法名称:</span>
|
||||
<span class="info-value">{{ crawler.methodName }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">执行周期:</span>
|
||||
<span class="info-value">{{ crawler.cronExpression }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="crawler.description">
|
||||
<span class="info-label">描述:</span>
|
||||
<span class="info-value">{{ crawler.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
v-if="crawler.status === 0"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleStart(crawler)"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
启动
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handlePause(crawler)"
|
||||
>
|
||||
<el-icon><VideoPause /></el-icon>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleExecute(crawler)"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
执行一次
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(crawler)"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(crawler)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-if="!loading && crawlerList.length === 0"
|
||||
description="暂无爬虫配置"
|
||||
style="margin-top: 40px"
|
||||
/>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[9, 18, 36]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑爬虫' : '新增爬虫'"
|
||||
width="700px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="form-item">
|
||||
<span class="form-label required">爬虫名称</span>
|
||||
<el-input
|
||||
v-model="formData.taskName"
|
||||
placeholder="请输入爬虫名称"
|
||||
clearable
|
||||
/>
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑爬虫' : '新增爬虫'"
|
||||
width="700px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="form-item">
|
||||
<span class="form-label required">爬虫名称</span>
|
||||
<el-input
|
||||
v-model="formData.taskName"
|
||||
placeholder="请输入爬虫名称"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Bean名称</span>
|
||||
<el-input
|
||||
v-model="formData.beanName"
|
||||
placeholder="请输入Spring Bean名称(如:newsCrawlerTask)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">方法名称</span>
|
||||
<el-input
|
||||
v-model="formData.methodName"
|
||||
placeholder="请输入要执行的方法名(如:crawlNews)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">方法参数</span>
|
||||
<el-input
|
||||
v-model="formData.methodParams"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入方法参数(JSON格式,可选)"
|
||||
clearable
|
||||
/>
|
||||
<span class="form-tip">
|
||||
示例:{"source":"xinhua","category":"education"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Cron表达式</span>
|
||||
<el-input
|
||||
v-model="formData.cronExpression"
|
||||
placeholder="请输入Cron表达式"
|
||||
clearable
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="validateCron">验证</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="form-tip">
|
||||
常用示例:<br/>
|
||||
- 0 0 */6 * * ? (每6小时执行一次)<br/>
|
||||
- 0 0 8,12,18 * * ? (每天8点、12点、18点执行)<br/>
|
||||
- 0 0/30 * * * ? (每30分钟执行一次)
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">爬虫描述</span>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入爬虫描述"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">是否允许并发</span>
|
||||
<el-radio-group v-model="formData.concurrent">
|
||||
<el-radio :label="1">允许</el-radio>
|
||||
<el-radio :label="0">禁止</el-radio>
|
||||
</el-radio-group>
|
||||
<span class="form-tip">
|
||||
建议禁止并发,避免重复抓取
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Bean名称</span>
|
||||
<el-input
|
||||
v-model="formData.beanName"
|
||||
placeholder="请输入Spring Bean名称(如:newsCrawlerTask)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">方法名称</span>
|
||||
<el-input
|
||||
v-model="formData.methodName"
|
||||
placeholder="请输入要执行的方法名(如:crawlNews)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">方法参数</span>
|
||||
<el-input
|
||||
v-model="formData.methodParams"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入方法参数(JSON格式,可选)"
|
||||
clearable
|
||||
/>
|
||||
<span class="form-tip">
|
||||
示例:{"source":"xinhua","category":"education"}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Cron表达式</span>
|
||||
<el-input
|
||||
v-model="formData.cronExpression"
|
||||
placeholder="请输入Cron表达式"
|
||||
clearable
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="validateCron">验证</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="form-tip">
|
||||
常用示例:<br/>
|
||||
- 0 0 */6 * * ? (每6小时执行一次)<br/>
|
||||
- 0 0 8,12,18 * * ? (每天8点、12点、18点执行)<br/>
|
||||
- 0 0/30 * * * ? (每30分钟执行一次)
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">爬虫描述</span>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入爬虫描述"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">是否允许并发</span>
|
||||
<el-radio-group v-model="formData.concurrent">
|
||||
<el-radio :label="1">允许</el-radio>
|
||||
<el-radio :label="0">禁止</el-radio>
|
||||
</el-radio-group>
|
||||
<span class="form-tip">
|
||||
建议禁止并发,避免重复抓取
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -269,7 +271,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus, Search, Refresh, DocumentCopy, VideoPlay, VideoPause, Promotion, Edit, Delete } from '@element-plus/icons-vue';
|
||||
import { crontabApi } from '@/apis/crontab';
|
||||
import type { CrontabTask, PageParam } from '@/types';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'NewsCrawlerView'
|
||||
});
|
||||
// 数据状态
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -1,245 +1,247 @@
|
||||
<template>
|
||||
<div class="task-management">
|
||||
<div class="header">
|
||||
<h2>定时任务管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增任务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务名称</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务分组</span>
|
||||
<el-input
|
||||
v-model="searchForm.taskGroup"
|
||||
placeholder="请输入任务分组"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<span class="search-label">状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="运行中" :value="1" />
|
||||
<el-option label="已暂停" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
<AdminLayout title="定时任务" subtitle="定时任务管理">
|
||||
<div class="task-management">
|
||||
<div class="header">
|
||||
<h2>定时任务管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增任务
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-table
|
||||
:data="taskList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="150" />
|
||||
<el-table-column prop="taskGroup" label="任务分组" width="120" />
|
||||
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
|
||||
<el-table-column prop="methodName" label="方法名称" width="120" />
|
||||
<el-table-column prop="cronExpression" label="Cron表达式" min-width="150" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '运行中' : '已暂停' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="并发" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.concurrent === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ row.concurrent === 1 ? '允许' : '禁止' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 0"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleStart(row)"
|
||||
>
|
||||
启动
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handlePause(row)"
|
||||
>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleExecute(row)"
|
||||
>
|
||||
执行一次
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑任务' : '新增任务'"
|
||||
width="700px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="form-item">
|
||||
<span class="form-label required">任务名称</span>
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务名称</span>
|
||||
<el-input
|
||||
v-model="formData.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
v-model="searchForm.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">任务分组</span>
|
||||
<div class="search-item">
|
||||
<span class="search-label">任务分组</span>
|
||||
<el-input
|
||||
v-model="formData.taskGroup"
|
||||
placeholder="请输入任务分组(如:SYSTEM、BUSINESS)"
|
||||
v-model="searchForm.taskGroup"
|
||||
placeholder="请输入任务分组"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Bean名称</span>
|
||||
<el-input
|
||||
v-model="formData.beanName"
|
||||
placeholder="请输入Spring Bean名称"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">方法名称</span>
|
||||
<el-input
|
||||
v-model="formData.methodName"
|
||||
placeholder="请输入要执行的方法名"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">方法参数</span>
|
||||
<el-input
|
||||
v-model="formData.methodParams"
|
||||
placeholder="请输入方法参数(JSON格式,可选)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Cron表达式</span>
|
||||
<el-input
|
||||
v-model="formData.cronExpression"
|
||||
placeholder="请输入Cron表达式(如:0 0 2 * * ?)"
|
||||
<div class="search-item">
|
||||
<span class="search-label">状态</span>
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="请选择状态"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="validateCron">验证</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="form-tip">
|
||||
格式:秒 分 时 日 月 周 年(年可选)。
|
||||
示例:0 0 2 * * ? 表示每天凌晨2点执行
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">是否允许并发</span>
|
||||
<el-radio-group v-model="formData.concurrent">
|
||||
<el-radio :label="1">允许</el-radio>
|
||||
<el-radio :label="0">禁止</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">错过执行策略</span>
|
||||
<el-select v-model="formData.misfirePolicy" placeholder="请选择策略">
|
||||
<el-option label="立即执行" :value="1" />
|
||||
<el-option label="执行一次" :value="2" />
|
||||
<el-option label="放弃执行" :value="3" />
|
||||
<el-option label="运行中" :value="1" />
|
||||
<el-option label="已暂停" :value="0" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">任务描述</span>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入任务描述"
|
||||
/>
|
||||
<div class="search-actions">
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-table
|
||||
:data="taskList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="150" />
|
||||
<el-table-column prop="taskGroup" label="任务分组" width="120" />
|
||||
<el-table-column prop="beanName" label="Bean名称" min-width="150" />
|
||||
<el-table-column prop="methodName" label="方法名称" width="120" />
|
||||
<el-table-column prop="cronExpression" label="Cron表达式" min-width="150" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '运行中' : '已暂停' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="并发" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.concurrent === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ row.concurrent === 1 ? '允许' : '禁止' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 0"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handleStart(row)"
|
||||
>
|
||||
启动
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handlePause(row)"
|
||||
>
|
||||
暂停
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleExecute(row)"
|
||||
>
|
||||
执行一次
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑任务' : '新增任务'"
|
||||
width="700px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<div class="form-content">
|
||||
<div class="form-item">
|
||||
<span class="form-label required">任务名称</span>
|
||||
<el-input
|
||||
v-model="formData.taskName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">任务分组</span>
|
||||
<el-input
|
||||
v-model="formData.taskGroup"
|
||||
placeholder="请输入任务分组(如:SYSTEM、BUSINESS)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Bean名称</span>
|
||||
<el-input
|
||||
v-model="formData.beanName"
|
||||
placeholder="请输入Spring Bean名称"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">方法名称</span>
|
||||
<el-input
|
||||
v-model="formData.methodName"
|
||||
placeholder="请输入要执行的方法名"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">方法参数</span>
|
||||
<el-input
|
||||
v-model="formData.methodParams"
|
||||
placeholder="请输入方法参数(JSON格式,可选)"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label required">Cron表达式</span>
|
||||
<el-input
|
||||
v-model="formData.cronExpression"
|
||||
placeholder="请输入Cron表达式(如:0 0 2 * * ?)"
|
||||
clearable
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="validateCron">验证</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span class="form-tip">
|
||||
格式:秒 分 时 日 月 周 年(年可选)。
|
||||
示例:0 0 2 * * ? 表示每天凌晨2点执行
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">是否允许并发</span>
|
||||
<el-radio-group v-model="formData.concurrent">
|
||||
<el-radio :label="1">允许</el-radio>
|
||||
<el-radio :label="0">禁止</el-radio>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">错过执行策略</span>
|
||||
<el-select v-model="formData.misfirePolicy" placeholder="请选择策略">
|
||||
<el-option label="立即执行" :value="1" />
|
||||
<el-option label="执行一次" :value="2" />
|
||||
<el-option label="放弃执行" :value="3" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<span class="form-label">任务描述</span>
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入任务描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSubmit"
|
||||
:loading="submitting"
|
||||
>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -248,7 +250,10 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Plus, Search, Refresh } from '@element-plus/icons-vue';
|
||||
import { crontabApi } from '@/apis/crontab';
|
||||
import type { CrontabTask, PageParam } from '@/types';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'TaskManagementView'
|
||||
});
|
||||
// 数据状态
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -1,60 +1,65 @@
|
||||
<template>
|
||||
<div class="login-logs">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户名..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
<AdminLayout title="登录日志" subtitle="登录日志管理">
|
||||
<div class="login-logs">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户名..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<el-select v-model="loginStatus" placeholder="登录状态" style="width: 150px" clearable>
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
<el-button type="danger" @click="handleClear">清空日志</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" style="width: 100%">
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||
<el-table-column prop="location" label="登录地点" width="150" />
|
||||
<el-table-column prop="browser" label="浏览器" width="120" />
|
||||
<el-table-column prop="os" label="操作系统" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="信息" min-width="150" />
|
||||
<el-table-column prop="loginTime" label="登录时间" width="180" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
<el-select v-model="loginStatus" placeholder="登录状态" style="width: 150px" clearable>
|
||||
<el-option label="成功" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
<el-button type="danger" @click="handleClear">清空日志</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" style="width: 100%">
|
||||
<el-table-column prop="username" label="用户名" width="120" />
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||
<el-table-column prop="location" label="登录地点" width="150" />
|
||||
<el-table-column prop="browser" label="浏览器" width="120" />
|
||||
<el-table-column prop="os" label="操作系统" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="信息" min-width="150" />
|
||||
<el-table-column prop="loginTime" label="登录时间" width="180" />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'LoginLogsView'
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const loginStatus = ref('');
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
|
||||
@@ -1,72 +1,78 @@
|
||||
<template>
|
||||
<div class="operation-logs">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户名或操作..."
|
||||
style="width: 250px"
|
||||
clearable
|
||||
<AdminLayout title="操作日志" subtitle="操作日志管理">
|
||||
<div class="operation-logs">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户名或操作..."
|
||||
style="width: 250px"
|
||||
clearable
|
||||
/>
|
||||
<el-select v-model="operationType" placeholder="操作类型" style="width: 150px" clearable>
|
||||
<el-option label="新增" value="create" />
|
||||
<el-option label="修改" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="查询" value="read" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="logs" style="width: 100%">
|
||||
<el-table-column prop="username" label="操作人" width="120" />
|
||||
<el-table-column prop="module" label="操作模块" width="120" />
|
||||
<el-table-column prop="operation" label="操作类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getOperationType(row.operation)">
|
||||
{{ getOperationText(row.operation) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="操作描述" min-width="200" />
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||
<el-table-column prop="duration" label="耗时(ms)" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operationTime" label="操作时间" width="180" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
<el-select v-model="operationType" placeholder="操作类型" style="width: 150px" clearable>
|
||||
<el-option label="新增" value="create" />
|
||||
<el-option label="修改" value="update" />
|
||||
<el-option label="删除" value="delete" />
|
||||
<el-option label="查询" value="read" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<el-table :data="logs" style="width: 100%">
|
||||
<el-table-column prop="username" label="操作人" width="120" />
|
||||
<el-table-column prop="module" label="操作模块" width="120" />
|
||||
<el-table-column prop="operation" label="操作类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getOperationType(row.operation)">
|
||||
{{ getOperationText(row.operation) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="操作描述" min-width="200" />
|
||||
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
||||
<el-table-column prop="duration" label="耗时(ms)" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
|
||||
{{ row.status === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operationTime" label="操作时间" width="180" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'OperationLogsView'
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const operationType = ref('');
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
|
||||
@@ -1,98 +1,103 @@
|
||||
<template>
|
||||
<div class="system-config">
|
||||
<el-form :model="configForm" label-width="150px" class="config-form">
|
||||
<el-divider content-position="left">基本设置</el-divider>
|
||||
|
||||
<el-form-item label="系统名称">
|
||||
<el-input v-model="configForm.systemName" />
|
||||
</el-form-item>
|
||||
<AdminLayout title="系统配置" subtitle="系统配置">
|
||||
<div class="system-config">
|
||||
<el-form :model="configForm" label-width="150px" class="config-form">
|
||||
<el-divider content-position="left">基本设置</el-divider>
|
||||
|
||||
<el-form-item label="系统名称">
|
||||
<el-input v-model="configForm.systemName" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="系统Logo">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeLogoUpload"
|
||||
>
|
||||
<img v-if="configForm.logo" :src="configForm.logo" class="logo-preview" />
|
||||
<el-icon v-else class="logo-uploader-icon">+</el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统Logo">
|
||||
<el-upload
|
||||
class="logo-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeLogoUpload"
|
||||
>
|
||||
<img v-if="configForm.logo" :src="configForm.logo" class="logo-preview" />
|
||||
<el-icon v-else class="logo-uploader-icon">+</el-icon>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="版权信息">
|
||||
<el-input v-model="configForm.copyright" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版权信息">
|
||||
<el-input v-model="configForm.copyright" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">安全设置</el-divider>
|
||||
<el-divider content-position="left">安全设置</el-divider>
|
||||
|
||||
<el-form-item label="启用验证码">
|
||||
<el-switch v-model="configForm.enableCaptcha" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用验证码">
|
||||
<el-switch v-model="configForm.enableCaptcha" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码最小长度">
|
||||
<el-input-number v-model="configForm.minPasswordLength" :min="6" :max="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码最小长度">
|
||||
<el-input-number v-model="configForm.minPasswordLength" :min="6" :max="20" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="会话超时(分钟)">
|
||||
<el-input-number v-model="configForm.sessionTimeout" :min="5" :max="1440" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会话超时(分钟)">
|
||||
<el-input-number v-model="configForm.sessionTimeout" :min="5" :max="1440" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="登录失败锁定">
|
||||
<el-switch v-model="configForm.enableLoginLock" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录失败锁定">
|
||||
<el-switch v-model="configForm.enableLoginLock" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="锁定阈值(次)" v-if="configForm.enableLoginLock">
|
||||
<el-input-number v-model="configForm.loginLockThreshold" :min="3" :max="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="锁定阈值(次)" v-if="configForm.enableLoginLock">
|
||||
<el-input-number v-model="configForm.loginLockThreshold" :min="3" :max="10" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">功能设置</el-divider>
|
||||
<el-divider content-position="left">功能设置</el-divider>
|
||||
|
||||
<el-form-item label="启用用户注册">
|
||||
<el-switch v-model="configForm.enableRegister" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用用户注册">
|
||||
<el-switch v-model="configForm.enableRegister" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用评论功能">
|
||||
<el-switch v-model="configForm.enableComment" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用评论功能">
|
||||
<el-switch v-model="configForm.enableComment" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用文件上传">
|
||||
<el-switch v-model="configForm.enableFileUpload" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用文件上传">
|
||||
<el-switch v-model="configForm.enableFileUpload" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="文件上传大小限制(MB)">
|
||||
<el-input-number v-model="configForm.maxFileSize" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文件上传大小限制(MB)">
|
||||
<el-input-number v-model="configForm.maxFileSize" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">邮件设置</el-divider>
|
||||
<el-divider content-position="left">邮件设置</el-divider>
|
||||
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="configForm.enableEmail" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用邮件通知">
|
||||
<el-switch v-model="configForm.enableEmail" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="SMTP服务器" v-if="configForm.enableEmail">
|
||||
<el-input v-model="configForm.smtpHost" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP服务器" v-if="configForm.enableEmail">
|
||||
<el-input v-model="configForm.smtpHost" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="SMTP端口" v-if="configForm.enableEmail">
|
||||
<el-input-number v-model="configForm.smtpPort" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP端口" v-if="configForm.enableEmail">
|
||||
<el-input-number v-model="configForm.smtpPort" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="发件人邮箱" v-if="configForm.enableEmail">
|
||||
<el-input v-model="configForm.senderEmail" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件人邮箱" v-if="configForm.enableEmail">
|
||||
<el-input v-model="configForm.senderEmail" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存配置</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSave">保存配置</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElForm, ElFormItem, ElInput, ElInputNumber, ElSwitch, ElButton, ElDivider, ElUpload, ElIcon, ElMessage } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'SystemConfigView'
|
||||
});
|
||||
const configForm = ref({
|
||||
systemName: '红色思政学习平台',
|
||||
logo: '',
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<div class="system-logs">
|
||||
<h1 class="page-title">系统日志</h1>
|
||||
<AdminLayout title="系统日志" subtitle="系统日志管理">
|
||||
<div class="system-logs">
|
||||
<h1 class="page-title">系统日志</h1>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="登录日志" name="login">
|
||||
<LoginLogs />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="操作日志" name="operation">
|
||||
<OperationLogs />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="系统配置" name="config">
|
||||
<SystemConfig />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="登录日志" name="login">
|
||||
<LoginLogs />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="操作日志" name="operation">
|
||||
<OperationLogs />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="系统配置" name="config">
|
||||
<SystemConfig />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -22,7 +24,10 @@ import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import LoginLogs from './components/LoginLogs.vue';
|
||||
import OperationLogs from './components/OperationLogs.vue';
|
||||
import SystemConfig from './components/SystemConfig.vue';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'SystemLogsView'
|
||||
});
|
||||
const activeTab = ref('login');
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<div class="article-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增文章</el-button>
|
||||
<el-button @click="handleDataCollection">数据采集</el-button>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索文章..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="资源管理"
|
||||
subtitle="管理文章、资源、数据等内容"
|
||||
>
|
||||
<div class="article-management">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="showCreateDialog">+ 新增文章</el-button>
|
||||
<el-button @click="handleDataCollection">数据采集</el-button>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索文章..."
|
||||
style="width: 300px"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="articles" style="width: 100%">
|
||||
<el-table-column prop="title" label="文章标题" min-width="200" />
|
||||
@@ -61,10 +65,16 @@
|
||||
@edit="handleEditFromView"
|
||||
@close="showViewDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArticleManagementView'
|
||||
});
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage } from 'element-plus';
|
||||
import { useRouter } from 'vue-router';
|
||||
@@ -245,7 +255,9 @@ function handleCurrentChange(val: number) {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-management {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
<template>
|
||||
<div class="data-records">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="菜单管理" name="menu">
|
||||
<!-- 菜单管理已在manage/system中实现,这里可以引用或重新实现 -->
|
||||
<div class="redirect-info">
|
||||
<p>菜单管理功能已在系统管理模块中实现</p>
|
||||
<el-button type="primary" @click="goToMenuManage">前往菜单管理</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="资源管理"
|
||||
subtitle="管理文章、资源、数据等内容"
|
||||
>
|
||||
<div class="data-records">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="菜单管理" name="menu">
|
||||
<!-- 菜单管理已在manage/system中实现,这里可以引用或重新实现 -->
|
||||
<div class="redirect-info">
|
||||
<p>菜单管理功能已在系统管理模块中实现</p>
|
||||
<el-button type="primary" @click="goToMenuManage">前往菜单管理</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElTabs, ElTabPane, ElButton } from 'element-plus';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'DataRecordsView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const activeTab = ref('menu');
|
||||
@@ -27,7 +37,9 @@ function goToMenuManage() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data-records {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.redirect-info {
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
<template>
|
||||
<div class="resource-management">
|
||||
<h1 class="page-title">资源管理</h1>
|
||||
|
||||
<el-tabs v-model="activeTab" class="resource-tabs">
|
||||
<el-tab-pane label="文章储备" name="articles">
|
||||
<ArticleManagement />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="数据记录" name="data">
|
||||
<DataRecords />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="资源管理"
|
||||
subtitle="管理文章、资源、数据等内容"
|
||||
>
|
||||
<div class="resource-management">
|
||||
<el-empty description="请使用顶部标签页切换到对应的资源管理功能" />
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import ArticleManagement from './components/ArticleManagement.vue';
|
||||
import DataRecords from './components/DataRecords.vue';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
const activeTab = ref('articles');
|
||||
defineOptions({
|
||||
name: 'ResourceManagementView'
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-management {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #141F38;
|
||||
margin-bottom: 24px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
<template>
|
||||
<div class="course-management">
|
||||
<CourseList
|
||||
v-if="currentView === 'list'"
|
||||
@add="handleAdd"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
<CourseAdd
|
||||
v-else-if="currentView === 'add' || currentView === 'edit'"
|
||||
:courseID="currentCourseId"
|
||||
@success="handleSuccess"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="学习管理"
|
||||
subtitle="管理课程、学习任务、学习记录等信息"
|
||||
>
|
||||
<div class="course-management">
|
||||
<CourseList
|
||||
v-if="currentView === 'list'"
|
||||
@add="handleAdd"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
<CourseAdd
|
||||
v-else-if="currentView === 'add' || currentView === 'edit'"
|
||||
:courseID="currentCourseId"
|
||||
@success="handleSuccess"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { CourseList, CourseAdd } from '@/views/public/course/components';
|
||||
import type { Course } from '@/types/study';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'CourseManagementView'
|
||||
});
|
||||
|
||||
type ViewType = 'list' | 'add' | 'edit';
|
||||
|
||||
@@ -47,7 +57,8 @@ function handleCancel() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.course-management {
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
background: #FFFFFF;
|
||||
border-radius: 14px;
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
<template>
|
||||
<div class="study-management">
|
||||
<h1 class="page-title">学习管理</h1>
|
||||
<AdminLayout title="学习管理" subtitle="学习记录管理">
|
||||
<div class="study-management">
|
||||
<h1 class="page-title">学习管理</h1>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tabs v-model="activeTab">
|
||||
|
||||
<el-tab-pane label="学习记录" name="task-records">
|
||||
<StudyRecords />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<el-tab-pane label="学习记录" name="task-records">
|
||||
<StudyRecords />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ElTabs, ElTabPane } from 'element-plus';
|
||||
import StudyRecords from './components/StudyRecords.vue';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'StudyManagementView'
|
||||
});
|
||||
const activeTab = ref('task-publish');
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,70 +1,75 @@
|
||||
<template>
|
||||
<div class="study-records">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
|
||||
<el-option
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:label="task.name"
|
||||
:value="task.id"
|
||||
<AdminLayout title="学习记录" subtitle="学习记录管理">
|
||||
<div class="study-records">
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索用户..."
|
||||
style="width: 200px"
|
||||
clearable
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
<el-select v-model="selectedTask" placeholder="选择任务" style="width: 200px" clearable>
|
||||
<el-option
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:label="task.name"
|
||||
:value="task.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="180" />
|
||||
<el-table-column prop="progress" label="完成进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="学习时长" width="120" />
|
||||
<el-table-column prop="startDate" label="开始时间" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleExport">导出</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="records" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="taskName" label="任务名称" min-width="180" />
|
||||
<el-table-column prop="progress" label="完成进度" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-progress :percentage="row.progress" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration" label="学习时长" width="120" />
|
||||
<el-table-column prop="startDate" label="开始时间" width="150" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="viewDetail(row)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElProgress, ElPagination, ElMessage } from 'element-plus';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'StudyRecordsView'
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const selectedTask = ref('');
|
||||
const dateRange = ref<[Date, Date] | null>(null);
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<template>
|
||||
<div class="task-management">
|
||||
<LearningTaskList
|
||||
v-if="currentView === 'list'"
|
||||
@add="handleAdd"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
<LearningTaskAdd
|
||||
v-else-if="currentView === 'add' || currentView === 'edit'"
|
||||
:task-i-d="currentTaskId"
|
||||
@success="handleSuccess"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
<AdminLayout title="任务管理" subtitle="任务管理">
|
||||
<div class="task-management">
|
||||
<LearningTaskList
|
||||
v-if="currentView === 'list'"
|
||||
@add="handleAdd"
|
||||
@edit="handleEdit"
|
||||
/>
|
||||
<LearningTaskAdd
|
||||
v-else-if="currentView === 'add' || currentView === 'edit'"
|
||||
:task-i-d="currentTaskId"
|
||||
@success="handleSuccess"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { LearningTaskList, LearningTaskAdd } from '@/views/public/task';
|
||||
import type { LearningTask } from '@/types/study';
|
||||
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
defineOptions({
|
||||
name: 'TaskManageView'
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="dept-manage">
|
||||
<div class="header">
|
||||
<h2>部门管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增部门
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="系统管理"
|
||||
subtitle="管理用户、角色、权限、部门等系统信息"
|
||||
>
|
||||
<div class="dept-manage">
|
||||
<div class="header">
|
||||
<h2>部门管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增部门
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<el-tree
|
||||
@@ -163,12 +167,18 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { deptApi } from '@/apis/system/dept';
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import { SysDept, SysRole } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'DeptManageView'
|
||||
});
|
||||
import { ref, onMounted, reactive, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus, OfficeBuilding } from '@element-plus/icons-vue';
|
||||
@@ -605,7 +615,9 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dept-manage {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="menu-manage">
|
||||
<div class="header">
|
||||
<h2>菜单管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增菜单
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="系统管理"
|
||||
subtitle="管理用户、角色、权限、部门等系统信息"
|
||||
>
|
||||
<div class="menu-manage">
|
||||
<div class="header">
|
||||
<h2>菜单管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增菜单
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<el-tree
|
||||
@@ -208,12 +212,18 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { menuApi } from '@/apis/system/menu';
|
||||
import { permissionApi } from '@/apis/system/permission';
|
||||
import { SysMenu, SysPermission } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'MenuManageView'
|
||||
});
|
||||
import { ref, onMounted, reactive, computed } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
@@ -692,7 +702,9 @@ async function handleNodeDrop(draggingNode: any, dropNode: any, dropType: string
|
||||
|
||||
<style scoped lang="scss">
|
||||
.menu-manage {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="module-permission-manage">
|
||||
<div class="header">
|
||||
<h2>模块权限管理</h2>
|
||||
<el-button type="primary" @click="handleAddModule">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增模块
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="系统管理"
|
||||
subtitle="管理用户、角色、权限、部门等系统信息"
|
||||
>
|
||||
<div class="module-permission-manage">
|
||||
<div class="header">
|
||||
<h2>模块权限管理</h2>
|
||||
<el-button type="primary" @click="handleAddModule">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增模块
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 左侧:模块树 -->
|
||||
@@ -389,7 +393,8 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -401,6 +406,11 @@ import { permissionApi } from '@/apis/system/permission';
|
||||
import { menuApi } from '@/apis/system/menu';
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import type { SysModule, SysPermission, SysMenu, SysRole } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'ModulePermissionManageView'
|
||||
});
|
||||
|
||||
// 数据状态
|
||||
const moduleLoading = ref(false);
|
||||
@@ -873,9 +883,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.module-permission-manage {
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
height: calc(100vh - 120px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<template>
|
||||
<div class="role-manage">
|
||||
<div class="header">
|
||||
<h2>角色管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增角色
|
||||
</el-button>
|
||||
</div>
|
||||
<AdminLayout
|
||||
title="系统管理"
|
||||
subtitle="管理用户、角色、权限、部门等系统信息"
|
||||
>
|
||||
<div class="role-manage">
|
||||
<div class="header">
|
||||
<h2>角色管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增角色
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="roleList"
|
||||
@@ -148,13 +152,19 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import { permissionApi } from '@/apis/system/permission';
|
||||
import { SysRole, SysPermission } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
defineOptions({
|
||||
name: 'RoleManageView'
|
||||
});
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
@@ -415,7 +425,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-manage {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,217 +1,230 @@
|
||||
<template>
|
||||
<div class="user-manage">
|
||||
<div class="header">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="userList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
row-key="userID"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="120" />
|
||||
<el-table-column prop="realName" label="真实姓名" min-width="100" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
|
||||
{{ row.status === 0 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creatorName" label="创建人" width="120" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEdit(row)"
|
||||
link
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleBindDeptRole(row)"
|
||||
link
|
||||
>
|
||||
绑定部门角色
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
link
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新增/编辑用户对话框 -->
|
||||
<el-dialog
|
||||
v-model="userDialogVisible"
|
||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||
width="600px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form
|
||||
ref="userFormRef"
|
||||
:model="currentUser"
|
||||
:rules="userFormRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="currentUser.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password" v-if="!isEdit">
|
||||
<el-input
|
||||
v-model="currentUser.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="currentUser.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="currentUser.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input v-model="currentUser.realName" placeholder="请输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="currentUser.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="currentUser.status" placeholder="请选择状态">
|
||||
<el-option label="正常" :value="0" />
|
||||
<el-option label="禁用" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveUser" :loading="submitting">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
<AdminLayout
|
||||
title="系统管理"
|
||||
subtitle="管理用户、角色、权限、部门等系统信息"
|
||||
>
|
||||
<div class="user-manage">
|
||||
<div class="header">
|
||||
<h2>用户管理</h2>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增用户
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定部门角色对话框 -->
|
||||
<el-dialog
|
||||
v-model="bindDeptRoleDialogVisible"
|
||||
title="绑定部门角色"
|
||||
width="800px"
|
||||
@close="resetBindForm"
|
||||
>
|
||||
<div class="bind-info">
|
||||
<el-alert
|
||||
:title="`用户:${currentUser.username} (${currentUser.realName})`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-table :data="bindList" style="width: 100%" border stripe>
|
||||
<el-table-column width="80" label="绑定状态">
|
||||
<el-table
|
||||
:data="userList"
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
border
|
||||
stripe
|
||||
row-key="userID"
|
||||
>
|
||||
<el-table-column prop="username" label="用户名" min-width="120" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="phone" label="手机号" min-width="120" />
|
||||
<el-table-column prop="realName" label="真实姓名" min-width="100" />
|
||||
<el-table-column prop="nickname" label="昵称" min-width="100" />
|
||||
<el-table-column prop="deptName" label="部门" min-width="120" />
|
||||
<el-table-column prop="roleName" label="角色" min-width="120" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="isSelected(row) ? 'success' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ isSelected(row) ? '已绑定' : '未绑定' }}
|
||||
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
|
||||
{{ row.status === 0 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deptName" label="部门" min-width="150" />
|
||||
<el-table-column prop="roleName" label="角色" min-width="150" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:type="isSelected(row) ? 'danger' : 'primary'"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="toggleSelection(row)"
|
||||
@click="handleEdit(row)"
|
||||
link
|
||||
>
|
||||
{{ isSelected(row) ? '解绑' : '绑定' }}
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleBindDeptRole(row)"
|
||||
link
|
||||
>
|
||||
绑定部门角色
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="handleDelete(row)"
|
||||
link
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="bind-stats" style="margin-top: 20px">
|
||||
<el-alert
|
||||
:title="`已绑定 ${selectedBindings.length} 个部门角色,未绑定 ${bindList.length - selectedBindings.length} 个部门角色`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
<!-- 分页组件 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="pageParam.pageNumber"
|
||||
v-model:page-size="pageParam.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="bindDeptRoleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveBindings" :loading="submitting">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<!-- 新增/编辑用户对话框 -->
|
||||
<el-dialog
|
||||
v-model="userDialogVisible"
|
||||
:title="isEdit ? '编辑用户' : '新增用户'"
|
||||
width="600px"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form
|
||||
ref="userFormRef"
|
||||
:model="currentUser"
|
||||
:rules="userFormRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="currentUser.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password" v-if="!isEdit">
|
||||
<el-input
|
||||
v-model="currentUser.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="currentUser.email" placeholder="请输入邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="currentUser.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="真实姓名" prop="realName">
|
||||
<el-input v-model="currentUser.realName" placeholder="请输入真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="currentUser.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="currentUser.status">
|
||||
<el-radio :value="0">正常</el-radio>
|
||||
<el-radio :value="1">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUserForm" :loading="submitting">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 绑定部门角色对话框 -->
|
||||
<el-dialog
|
||||
v-model="bindDialogVisible"
|
||||
title="绑定部门角色"
|
||||
width="600px"
|
||||
@close="resetBindForm"
|
||||
>
|
||||
<el-form
|
||||
ref="bindFormRef"
|
||||
:model="bindForm"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="选择部门">
|
||||
<el-tree-select
|
||||
v-model="bindForm.deptId"
|
||||
:data="deptTree"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
placeholder="请选择部门"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择角色">
|
||||
<el-select v-model="bindForm.roleIds" multiple placeholder="请选择角色" style="width: 100%">
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.roleID"
|
||||
:label="role.name"
|
||||
:value="role.roleID"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindForm" :loading="binding">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { userApi } from '@/apis/system/user';
|
||||
import { deptApi } from '@/apis/system/dept';
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import type { SysUser, UserDeptRoleVO, UserVO, SysDeptRole} from '@/types';
|
||||
import { userApi, deptApi, roleApi } from '@/apis/system';
|
||||
import type { SysUser, SysRole, PageParam, UserVO, UserDeptRoleVO } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
|
||||
// 扩展部门角色类型,包含绑定状态和显示信息
|
||||
interface DeptRoleBindItem extends SysDeptRole {
|
||||
deptName?: string;
|
||||
roleName?: string;
|
||||
isBound?: boolean;
|
||||
}
|
||||
defineOptions({
|
||||
name: 'UserManageView'
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const userList = ref<UserVO[]>([]);
|
||||
const binding = ref(false);
|
||||
|
||||
const userList = ref<UserVO[]>([]);
|
||||
const deptTree = ref<any[]>([]);
|
||||
const roles = ref<SysRole[]>([]);
|
||||
|
||||
// 分页参数
|
||||
const pageParam = ref<PageParam>({
|
||||
pageNumber: 1,
|
||||
pageSize: 10
|
||||
});
|
||||
const total = ref(0);
|
||||
|
||||
// 对话框控制
|
||||
const userDialogVisible = ref(false);
|
||||
const bindDeptRoleDialogVisible = ref(false);
|
||||
const bindDialogVisible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
|
||||
// 当前操作的用户
|
||||
const currentUser = ref<SysUser & { realName?: string; nickname?: string }>({});
|
||||
const userFormRef = ref<FormInstance>();
|
||||
const bindFormRef = ref<FormInstance>();
|
||||
|
||||
// 绑定相关数据
|
||||
const bindList = ref<DeptRoleBindItem[]>([]);
|
||||
const selectedBindings = ref<string[]>([]); // 存储选中的绑定项的唯一标识
|
||||
const currentUser = ref<UserVO & { password?: string }>({
|
||||
status: 0
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const userFormRules = {
|
||||
const bindForm = ref<{
|
||||
userId?: string;
|
||||
deptId?: string;
|
||||
roleIds: string[];
|
||||
}>({
|
||||
roleIds: []
|
||||
});
|
||||
|
||||
const userFormRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
@@ -219,34 +232,20 @@ const userFormRules = {
|
||||
]
|
||||
};
|
||||
|
||||
// 生成唯一标识
|
||||
function getBindingKey(item: DeptRoleBindItem): string {
|
||||
return `${item.deptID}_${item.roleID}`;
|
||||
}
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
loadDepts();
|
||||
loadRoles();
|
||||
});
|
||||
|
||||
// 检查是否已选中
|
||||
function isSelected(item: DeptRoleBindItem): boolean {
|
||||
return selectedBindings.value.includes(getBindingKey(item));
|
||||
}
|
||||
|
||||
// 切换选择状态
|
||||
function toggleSelection(item: DeptRoleBindItem) {
|
||||
const key = getBindingKey(item);
|
||||
const index = selectedBindings.value.indexOf(key);
|
||||
|
||||
if (index > -1) {
|
||||
selectedBindings.value.splice(index, 1);
|
||||
} else {
|
||||
selectedBindings.value.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUserList() {
|
||||
async function loadUsers() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await userApi.getUserList({id: ""});
|
||||
userList.value = result.dataList || [];
|
||||
const result = await userApi.getUserPage(pageParam.value);
|
||||
if (result.success) {
|
||||
userList.value = result.dataList || [];
|
||||
total.value = result.pageParam?.totalElements || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error);
|
||||
ElMessage.error('加载用户列表失败');
|
||||
@@ -255,267 +254,176 @@ async function loadUserList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载部门和角色数据用于名称映射
|
||||
async function loadDeptAndRoleData() {
|
||||
async function loadDepts() {
|
||||
try {
|
||||
const [deptResult, roleResult] = await Promise.all([
|
||||
deptApi.getAllDepts(),
|
||||
roleApi.getAllRoles()
|
||||
]);
|
||||
|
||||
const depts = deptResult.dataList || [];
|
||||
const roles = roleResult.dataList || [];
|
||||
|
||||
const deptMap = new Map(depts.map(d => [d.deptID, d.name || '未知部门']));
|
||||
const roleMap = new Map(roles.map(r => [r.roleID, r.name || '未知角色']));
|
||||
|
||||
return { deptMap, roleMap };
|
||||
const result = await deptApi.getAllDepts();
|
||||
if (result.success) {
|
||||
deptTree.value = result.dataList || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门角色数据失败:', error);
|
||||
return { deptMap: new Map(), roleMap: new Map() };
|
||||
console.error('加载部门列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载部门角色绑定列表
|
||||
async function loadDeptRoleList(): Promise<DeptRoleBindItem[]> {
|
||||
async function loadRoles() {
|
||||
try {
|
||||
const [deptRoleResult, { deptMap, roleMap }] = await Promise.all([
|
||||
deptApi.getDeptRoleList({id: ""}),
|
||||
loadDeptAndRoleData()
|
||||
]);
|
||||
|
||||
const deptRoles = deptRoleResult.dataList || [];
|
||||
|
||||
return deptRoles.map(item => ({
|
||||
...item,
|
||||
deptName: deptMap.get(item.deptID || '') || `部门${item.deptID}`,
|
||||
roleName: roleMap.get(item.roleID || '') || `角色${item.roleID}`,
|
||||
isBound: false // 初始状态为未绑定,需要根据实际绑定情况设置
|
||||
}));
|
||||
const result = await roleApi.getRoleList({});
|
||||
if (result.success) {
|
||||
roles.value = result.dataList || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载部门角色列表失败:', error);
|
||||
return [];
|
||||
console.error('加载角色列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 新增用户
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
currentUser.value = {};
|
||||
currentUser.value = { status: 0 };
|
||||
userDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
function handleEdit(row: UserVO) {
|
||||
isEdit.value = true;
|
||||
// 将UserVO转换为SysUser
|
||||
currentUser.value = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
wechatID: row.wechatID,
|
||||
status: row.status,
|
||||
createTime: row.createTime,
|
||||
updateTime: row.updateTime,
|
||||
deleteTime: row.deleteTime,
|
||||
deleted: row.deleted,
|
||||
realName: row.realName,
|
||||
nickname: row.nickname
|
||||
};
|
||||
currentUser.value = { ...row };
|
||||
userDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function handleDelete(row: UserVO) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除用户 "${row.username}" 吗?`,
|
||||
'确认删除',
|
||||
`确定要删除用户"${row.username}"吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
type: 'warning',
|
||||
}
|
||||
);
|
||||
|
||||
await userApi.deleteUser(row);
|
||||
ElMessage.success('删除成功');
|
||||
await loadUserList();
|
||||
} catch (error) {
|
||||
|
||||
const result = await userApi.deleteUser({ id: row.id } as SysUser);
|
||||
if (result.success) {
|
||||
ElMessage.success('删除成功');
|
||||
loadUsers();
|
||||
} else {
|
||||
ElMessage.error(result.message || '删除失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除用户失败:', error);
|
||||
ElMessage.error('删除用户失败');
|
||||
ElMessage.error('删除失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户
|
||||
async function saveUser() {
|
||||
try {
|
||||
submitting.value = true;
|
||||
|
||||
if (isEdit.value) {
|
||||
await userApi.updateUser(currentUser.value);
|
||||
ElMessage.success('更新成功');
|
||||
} else {
|
||||
await userApi.createUser(currentUser.value);
|
||||
ElMessage.success('创建成功');
|
||||
}
|
||||
|
||||
userDialogVisible.value = false;
|
||||
await loadUserList();
|
||||
} catch (error) {
|
||||
console.error('保存用户失败:', error);
|
||||
ElMessage.error('保存用户失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定部门角色
|
||||
async function handleBindDeptRole(row: UserVO) {
|
||||
// 将UserVO转换为SysUser
|
||||
currentUser.value = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
phone: row.phone,
|
||||
wechatID: row.wechatID,
|
||||
status: row.status,
|
||||
createTime: row.createTime,
|
||||
updateTime: row.updateTime,
|
||||
deleteTime: row.deleteTime,
|
||||
deleted: row.deleted
|
||||
function handleBindDeptRole(row: UserVO) {
|
||||
bindForm.value = {
|
||||
userId: row.id,
|
||||
deptId: undefined,
|
||||
roleIds: []
|
||||
};
|
||||
|
||||
try {
|
||||
// 加载全部部门和角色数据
|
||||
bindList.value = await loadDeptRoleList();
|
||||
|
||||
// 加载已绑定部门角色
|
||||
const deptRoleResult = await userApi.getUserDeptRole({userID: currentUser.value.id});
|
||||
const deptRoles = deptRoleResult.dataList || [];
|
||||
|
||||
// 初始化选中状态为已绑定的项
|
||||
selectedBindings.value = bindList.value
|
||||
.filter(item => deptRoles.some(deptRole =>
|
||||
deptRole.deptID === item.deptID && deptRole.roleID === item.roleID
|
||||
))
|
||||
.map(item => getBindingKey(item));
|
||||
|
||||
bindDeptRoleDialogVisible.value = true;
|
||||
} catch (error) {
|
||||
console.error('加载绑定数据失败:', error);
|
||||
ElMessage.error('加载绑定数据失败');
|
||||
}
|
||||
bindDialogVisible.value = true;
|
||||
}
|
||||
|
||||
// 保存绑定设置
|
||||
async function saveBindings() {
|
||||
if (!currentUser.value || !currentUser.value.id) {
|
||||
ElMessage.error('用户信息不完整');
|
||||
return;
|
||||
}
|
||||
|
||||
async function submitUserForm() {
|
||||
if (!userFormRef.value) return;
|
||||
|
||||
try {
|
||||
await userFormRef.value.validate();
|
||||
submitting.value = true;
|
||||
|
||||
// 获取当前已绑定的部门角色
|
||||
const currentBoundResult = await userApi.getUserDeptRole({userID: currentUser.value.id});
|
||||
const currentBoundItems = currentBoundResult.dataList || [];
|
||||
const currentBoundKeys = currentBoundItems.map(item => `${item.deptID}_${item.roleID}`);
|
||||
|
||||
// 找出需要绑定的项(新增的)
|
||||
const itemsToBind = bindList.value.filter(item => {
|
||||
const key = getBindingKey(item);
|
||||
return selectedBindings.value.includes(key) && !currentBoundKeys.includes(key);
|
||||
});
|
||||
|
||||
// 找出需要解绑的项(移除的)
|
||||
const itemsToUnbind = bindList.value.filter(item => {
|
||||
const key = getBindingKey(item);
|
||||
return !selectedBindings.value.includes(key) && currentBoundKeys.includes(key);
|
||||
});
|
||||
|
||||
// 执行绑定操作 - 一次性发送所有绑定项
|
||||
if (itemsToBind.length > 0) {
|
||||
const userDeptRoleVO: UserDeptRoleVO = {
|
||||
user: currentUser.value,
|
||||
users: [currentUser.value],
|
||||
userDeptRoles: itemsToBind.map(item => ({
|
||||
deptID: item.deptID,
|
||||
roleID: item.roleID
|
||||
}))
|
||||
};
|
||||
await userApi.bindUserDeptRole(userDeptRoleVO);
|
||||
|
||||
let result;
|
||||
if (isEdit.value) {
|
||||
result = await userApi.updateUser(currentUser.value as SysUser);
|
||||
} else {
|
||||
result = await userApi.createUser(currentUser.value as SysUser);
|
||||
}
|
||||
|
||||
// 执行解绑操作 - 一次性发送所有解绑项
|
||||
if (itemsToUnbind.length > 0) {
|
||||
const userDeptRoleVO: UserDeptRoleVO = {
|
||||
user: currentUser.value,
|
||||
users: [currentUser.value],
|
||||
userDeptRoles: itemsToUnbind.map(item => ({
|
||||
deptID: item.deptID,
|
||||
roleID: item.roleID
|
||||
}))
|
||||
};
|
||||
await userApi.unbindUserDeptRole(userDeptRoleVO);
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
userDialogVisible.value = false;
|
||||
loadUsers();
|
||||
} else {
|
||||
ElMessage.error(result.message || (isEdit.value ? '更新失败' : '创建失败'));
|
||||
}
|
||||
|
||||
ElMessage.success('部门角色绑定保存成功');
|
||||
bindDeptRoleDialogVisible.value = false;
|
||||
|
||||
// 刷新用户列表
|
||||
await loadUserList();
|
||||
} catch (error) {
|
||||
console.error('保存部门角色绑定失败:', error);
|
||||
ElMessage.error('保存部门角色绑定失败');
|
||||
console.error('提交失败:', error);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
async function submitBindForm() {
|
||||
try {
|
||||
binding.value = true;
|
||||
|
||||
// 构建 UserDeptRoleVO 对象
|
||||
const userDeptRoleVO: UserDeptRoleVO = {
|
||||
user: { id: bindForm.value.userId } as SysUser,
|
||||
depts: bindForm.value.deptId ? [{ id: bindForm.value.deptId }] : [],
|
||||
roles: bindForm.value.roleIds.map(roleId => ({ id: roleId }))
|
||||
};
|
||||
|
||||
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('绑定成功');
|
||||
bindDialogVisible.value = false;
|
||||
loadUsers();
|
||||
} else {
|
||||
ElMessage.error(result.message || '绑定失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('绑定失败:', error);
|
||||
ElMessage.error('绑定失败');
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentUser.value = {};
|
||||
userFormRef.value?.resetFields();
|
||||
}
|
||||
|
||||
// 重置绑定表单
|
||||
function resetBindForm() {
|
||||
bindList.value = [];
|
||||
selectedBindings.value = [];
|
||||
bindFormRef.value?.resetFields();
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadUserList();
|
||||
});
|
||||
function handlePageChange(page: number) {
|
||||
pageParam.value.pageNumber = page;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pageParam.value.pageSize = size;
|
||||
pageParam.value.pageNumber = 1; // 改变每页数量时,重置到第一页
|
||||
loadUsers();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style lang="scss" scoped>
|
||||
.user-manage {
|
||||
padding: 20px;
|
||||
background: #FFFFFF;
|
||||
padding: 24px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #101828;
|
||||
}
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bind-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.bind-stats {
|
||||
margin-top: 20px;
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,324 +1,48 @@
|
||||
<template>
|
||||
<div class="article-add-view">
|
||||
<div class="page-header">
|
||||
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
|
||||
<h1 class="page-title">{{ isEdit ? '编辑文章' : '创建文章' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="article-form">
|
||||
<el-form ref="formRef" :model="articleForm" :rules="rules" label-width="100px" label-position="top">
|
||||
<!-- 标题 -->
|
||||
<el-form-item label="文章标题" prop="resource.title">
|
||||
<el-input v-model="articleForm.resource.title" placeholder="请输入文章标题" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 分类和标签 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="文章分类" prop="resource.tagID">
|
||||
<el-select v-model="articleForm.resource.tagID" placeholder="请选择分类" style="width: 100%" :loading="categoryLoading" value-key="tagID">
|
||||
<el-option
|
||||
v-for="category in categoryList"
|
||||
:key="category.tagID || category.id"
|
||||
:label="category.name"
|
||||
:value="category.tagID||''"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<el-form-item label="封面图片">
|
||||
<FileUpload
|
||||
:as-dialog="false"
|
||||
list-type="cover"
|
||||
v-model:cover-url="articleForm.resource.coverImage"
|
||||
accept="image/*"
|
||||
:max-size="2"
|
||||
module="article"
|
||||
tip="建议尺寸 800x450"
|
||||
@success="handleCoverUploadSuccess"
|
||||
@remove="removeCover"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<el-form-item label="文章内容" prop="resource.content">
|
||||
<RichTextComponent ref="editorRef" v-model="articleForm.resource.content" height="500px"
|
||||
placeholder="请输入文章内容..." />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 发布设置 -->
|
||||
<el-form-item label="发布设置">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.allowComment">允许评论</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.isTop">置顶文章</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.isRecommend">推荐文章</el-checkbox>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handlePublish" :loading="publishing">
|
||||
{{ isEdit ? '保存修改' : '立即发布' }}
|
||||
</el-button>
|
||||
<el-button @click="handleSaveDraft" :loading="savingDraft">
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button @click="handlePreview">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button @click="handleBack">
|
||||
取消
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 文章预览组件 -->
|
||||
<ArticleShowView
|
||||
v-model="previewVisible"
|
||||
:as-dialog="true"
|
||||
title="文章预览"
|
||||
width="900px"
|
||||
:article-data="articleForm.resource"
|
||||
:category-list="categoryList"
|
||||
:show-edit-button="false"
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
<ArticleAdd
|
||||
v-if="articleId !== undefined"
|
||||
:article-id="articleId"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回"
|
||||
@back="handleBack"
|
||||
@publish-success="handlePublishSuccess"
|
||||
@save-draft-success="handleSaveDraftSuccess"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElButton,
|
||||
ElRow,
|
||||
ElCol,
|
||||
ElCheckbox,
|
||||
ElMessage
|
||||
} from 'element-plus';
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { RichTextComponent } from '@/components/text';
|
||||
import { FileUpload } from '@/components/file';
|
||||
import { ArticleShowView } from './index';
|
||||
import { resourceTagApi, resourceApi } from '@/apis/resource';
|
||||
import { ResourceVO, Tag, TagType } from '@/types/resource';
|
||||
import { ArticleAdd } from './components';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArticleAddView'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const formRef = ref();
|
||||
const editorRef = ref();
|
||||
const publishing = ref(false);
|
||||
const savingDraft = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const articleId = computed(() => route.query.id as string | undefined);
|
||||
|
||||
// 是否编辑模式
|
||||
const isEdit = ref(false);
|
||||
|
||||
// 数据状态
|
||||
const categoryList = ref<Tag[]>([]); // 改为使用Tag类型(tagType=1表示文章分类)
|
||||
const tagList = ref<Tag[]>([]);
|
||||
const categoryLoading = ref(false);
|
||||
const tagLoading = ref(false);
|
||||
|
||||
// 表单数据
|
||||
const articleForm = ref<ResourceVO>({
|
||||
resource: {
|
||||
},
|
||||
tags: [],
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
'resource.title': [
|
||||
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
|
||||
],
|
||||
'resource.tagID': [
|
||||
{ required: true, message: '请选择文章分类', trigger: 'change' }
|
||||
],
|
||||
'resource.content': [
|
||||
{ required: true, message: '请输入文章内容', trigger: 'blur' }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
// 加载分类列表(使用标签API,tagType=1表示文章分类标签)
|
||||
async function loadCategoryList() {
|
||||
try {
|
||||
categoryLoading.value = true;
|
||||
// 使用新的标签API获取文章分类标签(tagType=1)
|
||||
const result = await resourceTagApi.getTagsByType(TagType.ARTICLE_CATEGORY);
|
||||
if (result.success) {
|
||||
// 数组数据从 dataList 获取
|
||||
categoryList.value = result.dataList || [];
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载分类失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
ElMessage.error('加载分类失败');
|
||||
} finally {
|
||||
categoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载标签列表
|
||||
async function loadTagList() {
|
||||
try {
|
||||
tagLoading.value = true;
|
||||
const result = await resourceTagApi.getTagList();
|
||||
if (result.success) {
|
||||
// 数组数据从 dataList 获取
|
||||
tagList.value = result.dataList || [];
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载标签失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载标签失败:', error);
|
||||
ElMessage.error('加载标签失败');
|
||||
} finally {
|
||||
tagLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
// 返回上一页
|
||||
function handleBack() {
|
||||
router.back();
|
||||
router.back();
|
||||
}
|
||||
|
||||
// 发布文章
|
||||
async function handlePublish() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
|
||||
publishing.value = true;
|
||||
|
||||
// TODO: 调用API发布文章
|
||||
console.log('发布文章:', articleForm);
|
||||
resourceApi.createResource(articleForm.value).then(res => {
|
||||
if (res.success) {
|
||||
ElMessage.success('发布成功');
|
||||
} else {
|
||||
ElMessage.error(res.message || '发布失败');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
// 发布成功后跳转到文章详情页
|
||||
function handlePublishSuccess(id: string) {
|
||||
router.push({
|
||||
path: '/article/show',
|
||||
query: { articleId: id }
|
||||
});
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
async function handleSaveDraft() {
|
||||
savingDraft.value = true;
|
||||
|
||||
try {
|
||||
// TODO: 调用API保存草稿
|
||||
console.log('保存草稿:', articleForm);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
ElMessage.success('草稿已保存');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
ElMessage.error('保存失败');
|
||||
} finally {
|
||||
savingDraft.value = false;
|
||||
}
|
||||
// 保存草稿成功
|
||||
function handleSaveDraftSuccess() {
|
||||
// 可以添加其他逻辑
|
||||
console.log('草稿已保存');
|
||||
}
|
||||
|
||||
// 预览
|
||||
function handlePreview() {
|
||||
console.log(articleForm.value.resource.content);
|
||||
if (!articleForm.value.resource.title) {
|
||||
ElMessage.warning('请先输入文章标题');
|
||||
return;
|
||||
}
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
// 封面上传成功
|
||||
function handleCoverUploadSuccess(files: any[]) {
|
||||
if (files && files.length > 0) {
|
||||
// coverUrl已经通过v-model:cover-url自动更新了
|
||||
console.log('封面上传成功:', files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除封面
|
||||
function removeCover() {
|
||||
articleForm.value.resource.coverImage = '';
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载分类和标签数据
|
||||
await Promise.all([
|
||||
loadCategoryList(),
|
||||
loadTagList()
|
||||
]);
|
||||
|
||||
// 如果是编辑模式,加载文章数据
|
||||
const id = route.query.id;
|
||||
if (id) {
|
||||
try {
|
||||
isEdit.value = true;
|
||||
const result = await resourceApi.getResourceById(id as string);
|
||||
if (result.success && result.data) {
|
||||
articleForm.value = result.data;
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载文章失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文章失败:', error);
|
||||
ElMessage.error('加载文章失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-add-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.article-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,889 +1,31 @@
|
||||
<template>
|
||||
<!-- Dialog 模式 -->
|
||||
<el-dialog
|
||||
v-if="asDialog"
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="article-show-container">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentArticleData">
|
||||
<!-- 文章头部信息 -->
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ currentArticleData.title }}</h1>
|
||||
<div class="article-meta-info">
|
||||
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
|
||||
发布时间:{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
|
||||
浏览次数:{{ currentArticleData.viewCount }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.source">
|
||||
来源:{{ currentArticleData.source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="加载文章失败" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 路由页面模式 -->
|
||||
<div v-else class="article-page-view">
|
||||
<!-- 返回按钮 -->
|
||||
<div v-if="showBackButton" class="back-header">
|
||||
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="10" animated />
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div v-else-if="currentArticleData" class="article-wrapper">
|
||||
<div class="article-show-container">
|
||||
<!-- 文章头部信息 -->
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ currentArticleData.title }}</h1>
|
||||
<div class="article-meta-info">
|
||||
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
|
||||
发布时间:{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
|
||||
浏览次数:{{ currentArticleData.viewCount }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.source">
|
||||
来源:{{ currentArticleData.source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else class="error-container">
|
||||
<el-empty description="加载文章失败" />
|
||||
</div>
|
||||
</div>
|
||||
<ArticleShow
|
||||
:resource-id="articleId"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { learningRecordApi, learningHistoryApi } from '@/apis/study';
|
||||
import { useStore } from 'vuex';
|
||||
import type { ResourceCategory, Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
|
||||
import { ArticleShow } from './components';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArticleShowView'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean; // Dialog 模式下的显示状态
|
||||
asDialog?: boolean; // 是否作为 Dialog 使用
|
||||
title?: string; // Dialog 标题
|
||||
width?: string; // Dialog 宽度
|
||||
articleData?: Resource; // 文章数据(Dialog 模式使用)
|
||||
resourceID?: string; // 资源ID(路由模式使用)
|
||||
categoryList?: Array<ResourceCategory>; // 分类列表
|
||||
showEditButton?: boolean; // 是否显示编辑按钮
|
||||
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
|
||||
backButtonText?: string; // 返回按钮文本
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
asDialog: false, // 默认作为路由页面使用
|
||||
title: '文章预览',
|
||||
width: '900px',
|
||||
articleData: () => ({}),
|
||||
categoryList: () => [],
|
||||
showEditButton: false,
|
||||
showBackButton: true,
|
||||
backButtonText: '返回'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'close': [];
|
||||
'edit': [];
|
||||
'back': [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const loadedArticleData = ref<Resource | null>(null);
|
||||
const articleId = computed(() => route.query.articleId as string || '');
|
||||
|
||||
// 学习记录相关
|
||||
const learningRecord = ref<LearningRecord | null>(null);
|
||||
const learningStartTime = ref(0);
|
||||
const learningTimer = ref<number | null>(null);
|
||||
const hasVideoCompleted = ref(false);
|
||||
const totalVideos = ref(0); // 视频总数
|
||||
const completedVideos = ref<Set<number>>(new Set()); // 已完成的视频索引
|
||||
const userInfo = computed(() => store.getters['auth/user']);
|
||||
|
||||
// 学习历史记录相关
|
||||
const learningHistory = ref<TbLearningHistory | null>(null);
|
||||
const historyStartTime = ref(0);
|
||||
const historyTimer = ref<number | null>(null);
|
||||
|
||||
// 当前显示的文章数据
|
||||
const currentArticleData = computed(() => {
|
||||
// Dialog 模式使用传入的 articleData
|
||||
if (props.asDialog) {
|
||||
return props.articleData;
|
||||
}
|
||||
// 路由模式:优先使用传入的 articleData,否则使用加载的数据
|
||||
if (props.articleData && Object.keys(props.articleData).length > 0) {
|
||||
return props.articleData;
|
||||
}
|
||||
return loadedArticleData.value;
|
||||
});
|
||||
|
||||
// Dialog 显示状态
|
||||
const visible = computed({
|
||||
get: () => props.asDialog ? props.modelValue : false,
|
||||
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 路由模式下,从路由参数加载文章
|
||||
if (!props.asDialog) {
|
||||
const articleId = route.query.articleId as string;
|
||||
const taskId = route.query.taskId as string;
|
||||
|
||||
// 如果传入了 articleData,则不需要从路由加载
|
||||
if (props.articleData && Object.keys(props.articleData).length > 0) {
|
||||
loadedArticleData.value = props.articleData;
|
||||
|
||||
const resourceID = props.articleData.resourceID;
|
||||
if (resourceID) {
|
||||
// 创建学习历史记录(每次进入都创建新记录)
|
||||
createHistoryRecord(resourceID);
|
||||
|
||||
// 如果有taskId,还要创建学习记录
|
||||
if (taskId || route.query.taskId) {
|
||||
loadLearningRecord(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化视频监听
|
||||
nextTick().then(() => {
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300);
|
||||
});
|
||||
} else if (articleId) {
|
||||
// 从路由参数加载
|
||||
loadArticle(articleId);
|
||||
// 如果有 taskId,表示是任务学习,需要创建/加载学习记录
|
||||
if (taskId) {
|
||||
loadLearningRecord(articleId);
|
||||
}
|
||||
} else {
|
||||
// 既没有传入数据,也没有路由参数,显示错误
|
||||
ElMessage.error('文章ID不存在');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 组件销毁前保存学习进度
|
||||
if (learningRecord.value && !learningRecord.value.isComplete) {
|
||||
saveLearningProgress();
|
||||
}
|
||||
stopLearningTimer();
|
||||
|
||||
// 保存学习历史记录
|
||||
if (learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
}
|
||||
stopHistoryTimer();
|
||||
});
|
||||
|
||||
// 监听 articleData 变化(用于 ResourceArticle 切换文章)
|
||||
watch(() => props.articleData, async (newData, oldData) => {
|
||||
if (!props.asDialog) {
|
||||
// 如果从有数据变成null,或者切换到新文章,都需要保存当前历史
|
||||
if (learningHistory.value && (oldData && (!newData || oldData.resourceID !== newData.resourceID))) {
|
||||
await saveHistoryRecord();
|
||||
stopHistoryTimer();
|
||||
}
|
||||
|
||||
// 加载新文章数据
|
||||
if (newData && Object.keys(newData).length > 0) {
|
||||
loadedArticleData.value = newData;
|
||||
|
||||
// 为新文章创建学习历史记录
|
||||
const resourceID = newData.resourceID;
|
||||
if (resourceID) {
|
||||
await createHistoryRecord(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
// 重新初始化视频监听
|
||||
nextTick().then(() => {
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 加载文章数据
|
||||
async function loadArticle(resourceID: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await resourceApi.getResourceById(resourceID);
|
||||
if (res.success && res.data) {
|
||||
// ResourceVO 包含 resource 对象
|
||||
const resourceVO = res.data as ResourceVO;
|
||||
loadedArticleData.value = resourceVO.resource || res.data as Resource;
|
||||
|
||||
// 增加浏览次数
|
||||
await resourceApi.incrementViewCount(resourceID);
|
||||
|
||||
// 创建学习历史记录(每次进入都创建新记录)
|
||||
await createHistoryRecord(resourceID);
|
||||
|
||||
// 等待 DOM 更新后监听视频(增加延迟确保 DOM 完全渲染)
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300); // 延迟 300ms 确保 DOM 完全渲染
|
||||
} else {
|
||||
ElMessage.error(res.message || '加载文章失败');
|
||||
loadedArticleData.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文章失败:', error);
|
||||
ElMessage.error('加载文章失败');
|
||||
loadedArticleData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载学习记录
|
||||
async function loadLearningRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const res = await learningRecordApi.getRecordList({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1, // 资源类型:文章
|
||||
resourceID: resourceID
|
||||
});
|
||||
|
||||
if (res.success && res.dataList && res.dataList.length > 0) {
|
||||
learningRecord.value = res.dataList[0];
|
||||
|
||||
// 如果已完成,不需要启动计时器
|
||||
if (learningRecord.value.isComplete) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 没有记录,创建新的
|
||||
await createLearningRecord(resourceID);
|
||||
}
|
||||
|
||||
// 开始学习计时
|
||||
startLearningTimer();
|
||||
} catch (error) {
|
||||
console.error('加载学习记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建学习记录
|
||||
async function createLearningRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const taskId = route.query.taskId as string;
|
||||
const res = await learningRecordApi.createRecord({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1, // 资源类型:文章
|
||||
resourceID: resourceID,
|
||||
taskID: taskId || undefined,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
isComplete: false
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningRecord.value = res.data;
|
||||
ElMessage.success('开始学习');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建学习记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始学习计时
|
||||
function startLearningTimer() {
|
||||
learningStartTime.value = Date.now();
|
||||
|
||||
// 每10秒保存一次学习进度
|
||||
learningTimer.value = window.setInterval(() => {
|
||||
// 如果文章已完成,停止定时器
|
||||
if (learningRecord.value?.isComplete) {
|
||||
stopLearningTimer();
|
||||
return;
|
||||
}
|
||||
saveLearningProgress();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// 停止学习计时
|
||||
function stopLearningTimer() {
|
||||
if (learningTimer.value) {
|
||||
clearInterval(learningTimer.value);
|
||||
learningTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存学习进度
|
||||
async function saveLearningProgress() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
// 如果文章已完成,不再保存进度
|
||||
if (learningRecord.value.isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - learningStartTime.value) / 1000);
|
||||
|
||||
try {
|
||||
const updatedRecord = {
|
||||
id: learningRecord.value.id,
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1,
|
||||
resourceID: route.query.articleId as string,
|
||||
duration: (learningRecord.value.duration || 0) + duration,
|
||||
progress: hasVideoCompleted.value ? 100 : 50, // 如果视频播放完成,进度100%
|
||||
isComplete: hasVideoCompleted.value
|
||||
};
|
||||
|
||||
await learningRecordApi.updateRecord(updatedRecord);
|
||||
|
||||
// 更新本地记录
|
||||
learningRecord.value.duration = updatedRecord.duration;
|
||||
learningRecord.value.progress = updatedRecord.progress;
|
||||
learningRecord.value.isComplete = updatedRecord.isComplete;
|
||||
|
||||
// 重置开始时间
|
||||
learningStartTime.value = currentTime;
|
||||
|
||||
// 如果已完成,标记完成
|
||||
if (hasVideoCompleted.value) {
|
||||
await markArticleComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存学习进度失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记文章完成
|
||||
async function markArticleComplete() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
try {
|
||||
await learningRecordApi.markComplete({
|
||||
id: learningRecord.value.id,
|
||||
taskID: route.query.taskId as string,
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1,
|
||||
resourceID: route.query.articleId as string,
|
||||
isComplete: true
|
||||
});
|
||||
|
||||
ElMessage.success('文章学习完成!');
|
||||
stopLearningTimer();
|
||||
} catch (error) {
|
||||
console.error('标记完成失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化视频监听器
|
||||
function initVideoListeners() {
|
||||
|
||||
// 尝试多种选择器查找文章内容区域
|
||||
const selectors = [
|
||||
'.article-show-container .article-content.ql-editor',
|
||||
'.article-wrapper .article-content.ql-editor',
|
||||
'.article-content.ql-editor',
|
||||
'.article-show-container .article-content',
|
||||
'.article-wrapper .article-content',
|
||||
'.article-content'
|
||||
];
|
||||
|
||||
let articleContent: Element | null = null;
|
||||
|
||||
for (const selector of selectors) {
|
||||
articleContent = document.querySelector(selector);
|
||||
if (articleContent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!articleContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videos = articleContent.querySelectorAll('video');
|
||||
|
||||
if (videos.length === 0) {
|
||||
// 没有视频,默认阅读即完成
|
||||
hasVideoCompleted.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化视频数量和完成状态
|
||||
totalVideos.value = videos.length;
|
||||
completedVideos.value.clear();
|
||||
|
||||
// 监听所有视频的播放结束事件
|
||||
videos.forEach((video, index) => {
|
||||
const videoElement = video as HTMLVideoElement;
|
||||
|
||||
// 移除旧的监听器
|
||||
videoElement.removeEventListener('ended', () => handleVideoEnded(index));
|
||||
// 添加新的监听器,传递视频索引
|
||||
videoElement.addEventListener('ended', () => handleVideoEnded(index));
|
||||
});
|
||||
}
|
||||
|
||||
// 处理视频播放结束
|
||||
function handleVideoEnded(videoIndex: number) {
|
||||
// 标记该视频已完成
|
||||
completedVideos.value.add(videoIndex);
|
||||
|
||||
const completedCount = completedVideos.value.size;
|
||||
console.log(`✅ 视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
||||
|
||||
// 检查是否所有视频都已完成
|
||||
if (completedCount >= totalVideos.value) {
|
||||
if (!hasVideoCompleted.value) {
|
||||
hasVideoCompleted.value = true;
|
||||
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
|
||||
// 立即保存学习进度并标记完成
|
||||
saveLearningProgress();
|
||||
}
|
||||
} else {
|
||||
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 学习历史记录功能 ====================
|
||||
|
||||
// 创建学习历史记录
|
||||
async function createHistoryRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const res = await learningHistoryApi.recordResourceView(
|
||||
userInfo.value.id,
|
||||
resourceID,
|
||||
0 // 初始时长为0
|
||||
);
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log('✅ 学习历史记录创建成功:', learningHistory.value);
|
||||
|
||||
// 开始计时
|
||||
startHistoryTimer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 创建学习历史记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始学习历史计时
|
||||
function startHistoryTimer() {
|
||||
historyStartTime.value = Date.now();
|
||||
|
||||
// 每30秒保存一次学习历史
|
||||
historyTimer.value = window.setInterval(() => {
|
||||
saveHistoryRecord();
|
||||
}, 30000); // 30秒
|
||||
}
|
||||
|
||||
// 停止学习历史计时
|
||||
function stopHistoryTimer() {
|
||||
if (historyTimer.value) {
|
||||
clearInterval(historyTimer.value);
|
||||
historyTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存学习历史记录
|
||||
async function saveHistoryRecord() {
|
||||
if (!userInfo.value?.id || !learningHistory.value) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
|
||||
|
||||
// 如果时长太短(小于1秒),不保存
|
||||
if (duration < 1) return;
|
||||
|
||||
try {
|
||||
const updatedHistory: TbLearningHistory = {
|
||||
...learningHistory.value,
|
||||
duration: (learningHistory.value.duration || 0) + duration,
|
||||
endTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 调用API更新学习历史
|
||||
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log(`💾 学习历史已保存 - 累计时长: ${learningHistory.value.duration}秒`);
|
||||
}
|
||||
|
||||
// 重置开始时间
|
||||
historyStartTime.value = currentTime;
|
||||
} catch (error) {
|
||||
console.error('❌ 保存学习历史失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期(简单格式:YYYY-MM-DD)
|
||||
function formatDateSimple(date: string | Date): string {
|
||||
if (!date) return '';
|
||||
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 关闭处理
|
||||
function handleClose() {
|
||||
// 非Dialog模式下关闭时保存学习历史
|
||||
if (!props.asDialog && learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
stopHistoryTimer();
|
||||
}
|
||||
|
||||
if (props.asDialog) {
|
||||
visible.value = false;
|
||||
}
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function handleEdit() {
|
||||
emit('edit');
|
||||
}
|
||||
|
||||
// 返回处理
|
||||
// 返回上一页
|
||||
function handleBack() {
|
||||
// 返回前保存学习进度
|
||||
if (learningRecord.value && !learningRecord.value.isComplete) {
|
||||
saveLearningProgress();
|
||||
}
|
||||
stopLearningTimer();
|
||||
|
||||
const taskId = route.query.taskId as string;
|
||||
// 如果有 taskId,返回任务详情
|
||||
if (taskId) {
|
||||
router.push({
|
||||
path: '/study-plan/task-detail',
|
||||
query: { taskId }
|
||||
});
|
||||
} else {
|
||||
emit('back');
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
open: () => {
|
||||
if (props.asDialog) {
|
||||
visible.value = true;
|
||||
}
|
||||
},
|
||||
close: handleClose
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 路由页面模式样式
|
||||
.article-page-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.back-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.article-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
// Dialog 和路由模式共用的文章内容样式
|
||||
.article-show-container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
color: #141F38;
|
||||
margin: 0 0 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
margin-top: 30px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
color: #979797;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #E9E9E9;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: #334155;
|
||||
|
||||
// 继承富文本编辑器的样式
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
:deep(video),
|
||||
:deep(iframe) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// 对齐方式样式 - 图片和视频分别处理
|
||||
:deep(.ql-align-center) {
|
||||
text-align: center !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ql-align-right) {
|
||||
text-align: right !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ql-align-left) {
|
||||
text-align: left !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他富文本样式
|
||||
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||
margin: 24px 0 16px 0;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-left: 4px solid #409eff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(th), :deep(td) {
|
||||
border: 1px solid #ebeef5;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
background: #f5f7fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
332
schoolNewsWeb/src/views/public/article/components/ArticleAdd.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<template>
|
||||
<div class="article-add-view">
|
||||
<div class="page-header">
|
||||
<el-button @click="handleBack" :icon="ArrowLeft">返回</el-button>
|
||||
<h1 class="page-title">{{ isEdit ? '编辑文章' : '创建文章' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="article-form">
|
||||
<el-form ref="formRef" :model="articleForm" :rules="rules" label-width="100px" label-position="top">
|
||||
<!-- 标题 -->
|
||||
<el-form-item label="文章标题" prop="resource.title">
|
||||
<el-input v-model="articleForm.resource.title" placeholder="请输入文章标题" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 分类和标签 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="文章分类" prop="resource.tagID">
|
||||
<el-select v-model="articleForm.resource.tagID" placeholder="请选择分类" style="width: 100%" :loading="categoryLoading" value-key="tagID">
|
||||
<el-option
|
||||
v-for="category in categoryList"
|
||||
:key="category.tagID || category.id"
|
||||
:label="category.name"
|
||||
:value="category.tagID||''"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<el-form-item label="封面图片">
|
||||
<FileUpload
|
||||
:as-dialog="false"
|
||||
list-type="cover"
|
||||
v-model:cover-url="articleForm.resource.coverImage"
|
||||
accept="image/*"
|
||||
:max-size="2"
|
||||
module="article"
|
||||
tip="建议尺寸 800x450"
|
||||
@success="handleCoverUploadSuccess"
|
||||
@remove="removeCover"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<el-form-item label="文章内容" prop="resource.content">
|
||||
<RichTextComponent ref="editorRef" v-model="articleForm.resource.content" height="500px"
|
||||
placeholder="请输入文章内容..." />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 发布设置 -->
|
||||
<el-form-item label="发布设置">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.allowComment">允许评论</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.isTop">置顶文章</el-checkbox>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-checkbox v-model="articleForm.resource.isRecommend">推荐文章</el-checkbox>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handlePublish" :loading="publishing">
|
||||
{{ isEdit ? '保存修改' : '立即发布' }}
|
||||
</el-button>
|
||||
<el-button @click="handleSaveDraft" :loading="savingDraft">
|
||||
保存草稿
|
||||
</el-button>
|
||||
<el-button @click="handlePreview">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button @click="handleBack">
|
||||
取消
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 文章预览组件 -->
|
||||
<ArticleShow
|
||||
v-model="previewVisible"
|
||||
:as-dialog="true"
|
||||
title="文章预览"
|
||||
width="900px"
|
||||
:article-data="articleForm.resource"
|
||||
:category-list="categoryList"
|
||||
:show-edit-button="false"
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import {
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElButton,
|
||||
ElRow,
|
||||
ElCol,
|
||||
ElCheckbox,
|
||||
ElMessage
|
||||
} from 'element-plus';
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { RichTextComponent } from '@/components/text';
|
||||
import { FileUpload } from '@/components/file';
|
||||
import { ArticleShow } from '.';
|
||||
import { resourceTagApi, resourceApi } from '@/apis/resource';
|
||||
import { ResourceVO, Tag, TagType } from '@/types/resource';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArticleAdd'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
articleId?: string;
|
||||
showBackButton?: boolean;
|
||||
backButtonText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showBackButton: true,
|
||||
backButtonText: '返回'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'back': [];
|
||||
'publish-success': [id: string];
|
||||
'save-draft-success': [];
|
||||
}>();
|
||||
|
||||
const formRef = ref();
|
||||
const editorRef = ref();
|
||||
const publishing = ref(false);
|
||||
const savingDraft = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
|
||||
// 是否编辑模式
|
||||
const isEdit = ref(false);
|
||||
|
||||
// 数据状态
|
||||
const categoryList = ref<Tag[]>([]);
|
||||
const tagList = ref<Tag[]>([]);
|
||||
const categoryLoading = ref(false);
|
||||
const tagLoading = ref(false);
|
||||
|
||||
// 表单数据
|
||||
const articleForm = ref<ResourceVO>({
|
||||
resource: {},
|
||||
tags: [],
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
'resource.title': [
|
||||
{ required: true, message: '请输入文章标题', trigger: 'blur' },
|
||||
{ min: 5, max: 100, message: '标题长度在 5 到 100 个字符', trigger: 'blur' }
|
||||
],
|
||||
'resource.tagID': [
|
||||
{ required: true, message: '请选择文章分类', trigger: 'change' }
|
||||
],
|
||||
'resource.content': [
|
||||
{ required: true, message: '请输入文章内容', trigger: 'blur' }
|
||||
]
|
||||
};
|
||||
|
||||
// 加载分类列表
|
||||
async function loadCategoryList() {
|
||||
try {
|
||||
categoryLoading.value = true;
|
||||
const result = await resourceTagApi.getTagsByType(TagType.ARTICLE_CATEGORY);
|
||||
if (result.success) {
|
||||
categoryList.value = result.dataList || [];
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载分类失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载分类失败:', error);
|
||||
ElMessage.error('加载分类失败');
|
||||
} finally {
|
||||
categoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载标签列表
|
||||
async function loadTagList() {
|
||||
try {
|
||||
tagLoading.value = true;
|
||||
const result = await resourceTagApi.getTagList();
|
||||
if (result.success) {
|
||||
tagList.value = result.dataList || [];
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载标签失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载标签失败:', error);
|
||||
ElMessage.error('加载标签失败');
|
||||
} finally {
|
||||
tagLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 返回
|
||||
function handleBack() {
|
||||
emit('back');
|
||||
}
|
||||
|
||||
// 发布文章
|
||||
async function handlePublish() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
|
||||
publishing.value = true;
|
||||
|
||||
const result = await resourceApi.createResource(articleForm.value);
|
||||
if (result.success) {
|
||||
ElMessage.success('发布成功');
|
||||
emit('publish-success', result.data?.resource?.resourceID || '');
|
||||
} else {
|
||||
ElMessage.error(result.message || '发布失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存草稿
|
||||
async function handleSaveDraft() {
|
||||
savingDraft.value = true;
|
||||
|
||||
try {
|
||||
// TODO: 调用API保存草稿
|
||||
console.log('保存草稿:', articleForm);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
ElMessage.success('草稿已保存');
|
||||
emit('save-draft-success');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
ElMessage.error('保存失败');
|
||||
} finally {
|
||||
savingDraft.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 预览
|
||||
function handlePreview() {
|
||||
if (!articleForm.value.resource.title) {
|
||||
ElMessage.warning('请先输入文章标题');
|
||||
return;
|
||||
}
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
// 封面上传成功
|
||||
function handleCoverUploadSuccess(files: any[]) {
|
||||
if (files && files.length > 0) {
|
||||
console.log('封面上传成功:', files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除封面
|
||||
function removeCover() {
|
||||
articleForm.value.resource.coverImage = '';
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 并行加载分类和标签数据
|
||||
await Promise.all([
|
||||
loadCategoryList(),
|
||||
loadTagList()
|
||||
]);
|
||||
|
||||
// 如果是编辑模式,加载文章数据
|
||||
if (props.articleId) {
|
||||
try {
|
||||
isEdit.value = true;
|
||||
const result = await resourceApi.getResourceById(props.articleId);
|
||||
if (result.success && result.data) {
|
||||
articleForm.value = result.data;
|
||||
} else {
|
||||
ElMessage.error(result.message || '加载文章失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文章失败:', error);
|
||||
ElMessage.error('加载文章失败');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.article-add-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.article-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
<template>
|
||||
<!-- Dialog 模式 -->
|
||||
<el-dialog
|
||||
v-if="asDialog"
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="width"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="article-show-container">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="currentArticleData">
|
||||
<!-- 文章头部信息 -->
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ currentArticleData.title }}</h1>
|
||||
<div class="article-meta-info">
|
||||
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
|
||||
发布时间:{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
|
||||
浏览次数:{{ currentArticleData.viewCount }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.source">
|
||||
来源:{{ currentArticleData.source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
|
||||
</div>
|
||||
|
||||
<el-empty v-else description="加载文章失败" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button v-if="showEditButton" type="primary" @click="handleEdit">编辑</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 路由页面模式 -->
|
||||
<div v-else class="article-page-view">
|
||||
<!-- 返回按钮 -->
|
||||
<div v-if="showBackButton" class="back-header">
|
||||
<el-button @click="handleBack" :icon="ArrowLeft">{{ backButtonText }}</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="10" animated />
|
||||
</div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div v-else-if="currentArticleData" class="article-wrapper">
|
||||
<div class="article-show-container">
|
||||
<!-- 文章头部信息 -->
|
||||
<div class="article-header">
|
||||
<h1 class="article-title">{{ currentArticleData.title }}</h1>
|
||||
<div class="article-meta-info">
|
||||
<div class="meta-item" v-if="currentArticleData.publishTime || currentArticleData.createTime">
|
||||
发布时间:{{ formatDateSimple(currentArticleData.publishTime || currentArticleData.createTime || '') }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.viewCount !== undefined">
|
||||
浏览次数:{{ currentArticleData.viewCount }}
|
||||
</div>
|
||||
<div class="meta-item" v-if="currentArticleData.source">
|
||||
来源:{{ currentArticleData.source }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="separator"></div>
|
||||
|
||||
<!-- 文章内容 -->
|
||||
<div class="article-content ql-editor" v-html="currentArticleData.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div v-else class="error-container">
|
||||
<el-empty description="加载文章失败" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { ArrowLeft } from '@element-plus/icons-vue';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { learningRecordApi, learningHistoryApi } from '@/apis/study';
|
||||
import { useStore } from 'vuex';
|
||||
import type { Resource, ResourceVO, LearningRecord, TbLearningHistory } from '@/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'ArticleShow'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
modelValue?: boolean; // Dialog 模式下的显示状态
|
||||
asDialog?: boolean; // 是否作为 Dialog 使用
|
||||
title?: string; // Dialog 标题
|
||||
width?: string; // Dialog 宽度
|
||||
articleData?: Resource; // 文章数据(Dialog 模式使用)
|
||||
resourceID?: string; // 资源ID(路由模式使用)
|
||||
showEditButton?: boolean; // 是否显示编辑按钮
|
||||
showBackButton?: boolean; // 是否显示返回按钮(路由模式)
|
||||
backButtonText?: string; // 返回按钮文本
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
asDialog: false, // 默认作为路由页面使用
|
||||
title: '文章预览',
|
||||
width: '900px',
|
||||
articleData: () => ({}),
|
||||
showEditButton: false,
|
||||
showBackButton: true,
|
||||
backButtonText: '返回'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'close': [];
|
||||
'edit': [];
|
||||
'back': [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const loadedArticleData = ref<Resource | null>(null);
|
||||
|
||||
// 学习记录相关
|
||||
const learningRecord = ref<LearningRecord | null>(null);
|
||||
const learningStartTime = ref(0);
|
||||
const learningTimer = ref<number | null>(null);
|
||||
const hasVideoCompleted = ref(false);
|
||||
const totalVideos = ref(0); // 视频总数
|
||||
const completedVideos = ref<Set<number>>(new Set()); // 已完成的视频索引
|
||||
const userInfo = computed(() => store.getters['auth/user']);
|
||||
|
||||
// 学习历史记录相关
|
||||
const learningHistory = ref<TbLearningHistory | null>(null);
|
||||
const historyStartTime = ref(0);
|
||||
const historyTimer = ref<number | null>(null);
|
||||
|
||||
// 当前显示的文章数据
|
||||
const currentArticleData = computed(() => {
|
||||
// Dialog 模式使用传入的 articleData
|
||||
if (props.asDialog) {
|
||||
return props.articleData;
|
||||
}
|
||||
// 路由模式:优先使用传入的 articleData,否则使用加载的数据
|
||||
if (props.articleData && Object.keys(props.articleData).length > 0) {
|
||||
return props.articleData;
|
||||
}
|
||||
return loadedArticleData.value;
|
||||
});
|
||||
|
||||
// Dialog 显示状态
|
||||
const visible = computed({
|
||||
get: () => props.asDialog ? props.modelValue : false,
|
||||
set: (val) => props.asDialog ? emit('update:modelValue', val) : undefined
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// 路由模式下,从路由参数加载文章
|
||||
if (!props.asDialog) {
|
||||
const articleId = route.query.articleId as string;
|
||||
const taskId = route.query.taskId as string;
|
||||
|
||||
// 如果传入了 articleData,则不需要从路由加载
|
||||
if (props.articleData && Object.keys(props.articleData).length > 0) {
|
||||
loadedArticleData.value = props.articleData;
|
||||
|
||||
const resourceID = props.articleData.resourceID;
|
||||
if (resourceID) {
|
||||
// 创建学习历史记录(每次进入都创建新记录)
|
||||
createHistoryRecord(resourceID);
|
||||
|
||||
// 如果有taskId,还要创建学习记录
|
||||
if (taskId || route.query.taskId) {
|
||||
loadLearningRecord(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化视频监听
|
||||
nextTick().then(() => {
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300);
|
||||
});
|
||||
} else if (articleId) {
|
||||
// 从路由参数加载
|
||||
loadArticle(articleId);
|
||||
// 如果有 taskId,表示是任务学习,需要创建/加载学习记录
|
||||
if (taskId) {
|
||||
loadLearningRecord(articleId);
|
||||
}
|
||||
} else {
|
||||
// 既没有传入数据,也没有路由参数,显示错误
|
||||
ElMessage.error('文章ID不存在');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 组件销毁前保存学习进度
|
||||
if (learningRecord.value && !learningRecord.value.isComplete) {
|
||||
saveLearningProgress();
|
||||
}
|
||||
stopLearningTimer();
|
||||
|
||||
// 保存学习历史记录
|
||||
if (learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
}
|
||||
stopHistoryTimer();
|
||||
});
|
||||
|
||||
// 监听 articleData 变化(用于 ResourceArticle 切换文章)
|
||||
watch(() => props.articleData, async (newData, oldData) => {
|
||||
if (!props.asDialog) {
|
||||
// 如果从有数据变成null,或者切换到新文章,都需要保存当前历史
|
||||
if (learningHistory.value && (oldData && (!newData || oldData.resourceID !== newData.resourceID))) {
|
||||
await saveHistoryRecord();
|
||||
stopHistoryTimer();
|
||||
}
|
||||
|
||||
// 加载新文章数据
|
||||
if (newData && Object.keys(newData).length > 0) {
|
||||
loadedArticleData.value = newData;
|
||||
|
||||
// 为新文章创建学习历史记录
|
||||
const resourceID = newData.resourceID;
|
||||
if (resourceID) {
|
||||
await createHistoryRecord(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
// 重新初始化视频监听
|
||||
nextTick().then(() => {
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 加载文章数据
|
||||
async function loadArticle(resourceID: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await resourceApi.getResourceById(resourceID);
|
||||
if (res.success && res.data) {
|
||||
// ResourceVO 包含 resource 对象
|
||||
const resourceVO = res.data as ResourceVO;
|
||||
loadedArticleData.value = resourceVO.resource || res.data as Resource;
|
||||
|
||||
// 增加浏览次数
|
||||
await resourceApi.incrementViewCount(resourceID);
|
||||
|
||||
// 创建学习历史记录(每次进入都创建新记录)
|
||||
await createHistoryRecord(resourceID);
|
||||
|
||||
// 等待 DOM 更新后监听视频(增加延迟确保 DOM 完全渲染)
|
||||
await nextTick();
|
||||
setTimeout(() => {
|
||||
initVideoListeners();
|
||||
}, 300); // 延迟 300ms 确保 DOM 完全渲染
|
||||
} else {
|
||||
ElMessage.error(res.message || '加载文章失败');
|
||||
loadedArticleData.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载文章失败:', error);
|
||||
ElMessage.error('加载文章失败');
|
||||
loadedArticleData.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载学习记录
|
||||
async function loadLearningRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const res = await learningRecordApi.getRecordList({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1, // 资源类型:文章
|
||||
resourceID: resourceID
|
||||
});
|
||||
|
||||
if (res.success && res.dataList && res.dataList.length > 0) {
|
||||
learningRecord.value = res.dataList[0];
|
||||
|
||||
// 如果已完成,不需要启动计时器
|
||||
if (learningRecord.value.isComplete) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 没有记录,创建新的
|
||||
await createLearningRecord(resourceID);
|
||||
}
|
||||
|
||||
// 开始学习计时
|
||||
startLearningTimer();
|
||||
} catch (error) {
|
||||
console.error('加载学习记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建学习记录
|
||||
async function createLearningRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const taskId = route.query.taskId as string;
|
||||
const res = await learningRecordApi.createRecord({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1, // 资源类型:文章
|
||||
resourceID: resourceID,
|
||||
taskID: taskId || undefined,
|
||||
duration: 0,
|
||||
progress: 0,
|
||||
isComplete: false
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningRecord.value = res.data;
|
||||
ElMessage.success('开始学习');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建学习记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始学习计时
|
||||
function startLearningTimer() {
|
||||
learningStartTime.value = Date.now();
|
||||
|
||||
// 每10秒保存一次学习进度
|
||||
learningTimer.value = window.setInterval(() => {
|
||||
// 如果文章已完成,停止定时器
|
||||
if (learningRecord.value?.isComplete) {
|
||||
stopLearningTimer();
|
||||
return;
|
||||
}
|
||||
saveLearningProgress();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// 停止学习计时
|
||||
function stopLearningTimer() {
|
||||
if (learningTimer.value) {
|
||||
clearInterval(learningTimer.value);
|
||||
learningTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存学习进度
|
||||
async function saveLearningProgress() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
// 如果文章已完成,不再保存进度
|
||||
if (learningRecord.value.isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - learningStartTime.value) / 1000);
|
||||
|
||||
try {
|
||||
const updatedRecord = {
|
||||
id: learningRecord.value.id,
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1,
|
||||
resourceID: route.query.articleId as string,
|
||||
duration: (learningRecord.value.duration || 0) + duration,
|
||||
progress: hasVideoCompleted.value ? 100 : 50, // 如果视频播放完成,进度100%
|
||||
isComplete: hasVideoCompleted.value
|
||||
};
|
||||
|
||||
await learningRecordApi.updateRecord(updatedRecord);
|
||||
|
||||
// 更新本地记录
|
||||
learningRecord.value.duration = updatedRecord.duration;
|
||||
learningRecord.value.progress = updatedRecord.progress;
|
||||
learningRecord.value.isComplete = updatedRecord.isComplete;
|
||||
|
||||
// 重置开始时间
|
||||
learningStartTime.value = currentTime;
|
||||
|
||||
// 如果已完成,标记完成
|
||||
if (hasVideoCompleted.value) {
|
||||
await markArticleComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存学习进度失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 标记文章完成
|
||||
async function markArticleComplete() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
try {
|
||||
await learningRecordApi.markComplete({
|
||||
id: learningRecord.value.id,
|
||||
taskID: route.query.taskId as string,
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 1,
|
||||
resourceID: route.query.articleId as string,
|
||||
isComplete: true
|
||||
});
|
||||
|
||||
ElMessage.success('文章学习完成!');
|
||||
stopLearningTimer();
|
||||
} catch (error) {
|
||||
console.error('标记完成失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化视频监听器
|
||||
function initVideoListeners() {
|
||||
|
||||
// 尝试多种选择器查找文章内容区域
|
||||
const selectors = [
|
||||
'.article-show-container .article-content.ql-editor',
|
||||
'.article-wrapper .article-content.ql-editor',
|
||||
'.article-content.ql-editor',
|
||||
'.article-show-container .article-content',
|
||||
'.article-wrapper .article-content',
|
||||
'.article-content'
|
||||
];
|
||||
|
||||
let articleContent: Element | null = null;
|
||||
|
||||
for (const selector of selectors) {
|
||||
articleContent = document.querySelector(selector);
|
||||
if (articleContent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!articleContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videos = articleContent.querySelectorAll('video');
|
||||
|
||||
if (videos.length === 0) {
|
||||
// 没有视频,默认阅读即完成
|
||||
hasVideoCompleted.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化视频数量和完成状态
|
||||
totalVideos.value = videos.length;
|
||||
completedVideos.value.clear();
|
||||
|
||||
// 监听所有视频的播放结束事件
|
||||
videos.forEach((video, index) => {
|
||||
const videoElement = video as HTMLVideoElement;
|
||||
|
||||
// 移除旧的监听器
|
||||
videoElement.removeEventListener('ended', () => handleVideoEnded(index));
|
||||
// 添加新的监听器,传递视频索引
|
||||
videoElement.addEventListener('ended', () => handleVideoEnded(index));
|
||||
});
|
||||
}
|
||||
|
||||
// 处理视频播放结束
|
||||
function handleVideoEnded(videoIndex: number) {
|
||||
// 标记该视频已完成
|
||||
completedVideos.value.add(videoIndex);
|
||||
|
||||
const completedCount = completedVideos.value.size;
|
||||
console.log(`✅ 视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
||||
|
||||
// 检查是否所有视频都已完成
|
||||
if (completedCount >= totalVideos.value) {
|
||||
if (!hasVideoCompleted.value) {
|
||||
hasVideoCompleted.value = true;
|
||||
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
|
||||
// 立即保存学习进度并标记完成
|
||||
saveLearningProgress();
|
||||
}
|
||||
} else {
|
||||
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 学习历史记录功能 ====================
|
||||
|
||||
// 创建学习历史记录
|
||||
async function createHistoryRecord(resourceID: string) {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
try {
|
||||
const res = await learningHistoryApi.recordResourceView(
|
||||
userInfo.value.id,
|
||||
resourceID,
|
||||
0 // 初始时长为0
|
||||
);
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log('✅ 学习历史记录创建成功:', learningHistory.value);
|
||||
|
||||
// 开始计时
|
||||
startHistoryTimer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 创建学习历史记录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始学习历史计时
|
||||
function startHistoryTimer() {
|
||||
historyStartTime.value = Date.now();
|
||||
|
||||
// 每30秒保存一次学习历史
|
||||
historyTimer.value = window.setInterval(() => {
|
||||
saveHistoryRecord();
|
||||
}, 30000); // 30秒
|
||||
}
|
||||
|
||||
// 停止学习历史计时
|
||||
function stopHistoryTimer() {
|
||||
if (historyTimer.value) {
|
||||
clearInterval(historyTimer.value);
|
||||
historyTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存学习历史记录
|
||||
async function saveHistoryRecord() {
|
||||
if (!userInfo.value?.id || !learningHistory.value) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
|
||||
|
||||
// 如果时长太短(小于1秒),不保存
|
||||
if (duration < 1) return;
|
||||
|
||||
try {
|
||||
const updatedHistory: TbLearningHistory = {
|
||||
...learningHistory.value,
|
||||
duration: (learningHistory.value.duration || 0) + duration,
|
||||
endTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 调用API更新学习历史
|
||||
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log(`💾 学习历史已保存 - 累计时长: ${learningHistory.value.duration}秒`);
|
||||
}
|
||||
|
||||
// 重置开始时间
|
||||
historyStartTime.value = currentTime;
|
||||
} catch (error) {
|
||||
console.error('❌ 保存学习历史失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期(简单格式:YYYY-MM-DD)
|
||||
function formatDateSimple(date: string | Date): string {
|
||||
if (!date) return '';
|
||||
|
||||
const d = new Date(date);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 关闭处理
|
||||
function handleClose() {
|
||||
// 非Dialog模式下关闭时保存学习历史
|
||||
if (!props.asDialog && learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
stopHistoryTimer();
|
||||
}
|
||||
|
||||
if (props.asDialog) {
|
||||
visible.value = false;
|
||||
}
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// 编辑处理
|
||||
function handleEdit() {
|
||||
emit('edit');
|
||||
}
|
||||
|
||||
// 返回处理
|
||||
function handleBack() {
|
||||
// 返回前保存学习进度
|
||||
if (learningRecord.value && !learningRecord.value.isComplete) {
|
||||
saveLearningProgress();
|
||||
}
|
||||
stopLearningTimer();
|
||||
|
||||
const taskId = route.query.taskId as string;
|
||||
// 如果有 taskId,返回任务详情
|
||||
if (taskId) {
|
||||
router.push({
|
||||
path: '/study-plan/task-detail',
|
||||
query: { taskId }
|
||||
});
|
||||
} else {
|
||||
emit('back');
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
open: () => {
|
||||
if (props.asDialog) {
|
||||
visible.value = true;
|
||||
}
|
||||
},
|
||||
close: handleClose
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 路由页面模式样式
|
||||
.article-page-view {
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.back-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.article-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.error-container {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
// Dialog 和路由模式共用的文章内容样式
|
||||
.article-show-container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 600;
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
color: #141F38;
|
||||
margin: 0 0 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-meta-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 48px;
|
||||
margin-top: 30px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 28px;
|
||||
color: #979797;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: #E9E9E9;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.article-cover {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: #334155;
|
||||
|
||||
// 继承富文本编辑器的样式
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
:deep(video),
|
||||
:deep(iframe) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
// 对齐方式样式 - 图片和视频分别处理
|
||||
:deep(.ql-align-center) {
|
||||
text-align: center !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ql-align-right) {
|
||||
text-align: right !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ql-align-left) {
|
||||
text-align: left !important;
|
||||
|
||||
// 视频始终居中显示
|
||||
video, .custom-video {
|
||||
display: block !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
// 图片跟随文字对齐
|
||||
img, .custom-image {
|
||||
display: inline-block !important;
|
||||
vertical-align: bottom !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他富文本样式
|
||||
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
|
||||
margin: 24px 0 16px 0;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
:deep(p) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-left: 4px solid #409eff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul), :deep(ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
:deep(li) {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(th), :deep(td) {
|
||||
border: 1px solid #ebeef5;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
background: #f5f7fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ArticleAdd } from './ArticleAdd.vue';
|
||||
export { default as ArticleShow } from './ArticleShow.vue';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as ArticleAddView } from './ArticleAddView.vue';
|
||||
export { default as ArticleShowView } from './ArticleShowView.vue';
|
||||
export * from './card';
|
||||
export * from './card';
|
||||
export * from './components';
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="banner-card">
|
||||
<div class="banner-content" @click="handleLearn">
|
||||
<!-- <img :src="FILE_DOWNLOAD_URL + props.banner.imageUrl" alt="banner" class="banner-image"> -->
|
||||
<span>test</span>
|
||||
<img :src="FILE_DOWNLOAD_URL + props.banner.imageUrl" alt="banner" class="banner-image">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,12 +15,14 @@ const router = useRouter();
|
||||
const props = defineProps<{
|
||||
banner: Banner;
|
||||
}>();
|
||||
|
||||
console.log(props.banner);
|
||||
function handleLearn() {
|
||||
if (props.banner.linkType === 1) {
|
||||
router.push(`/resource/${props.banner.linkID}`);
|
||||
console.log(`/resource/${props.banner.linkID}`);
|
||||
router.push(`/article/show?articleId=${props.banner.linkID}`);
|
||||
} else if (props.banner.linkType === 2) {
|
||||
router.push(`/course/${props.banner.linkID}`);
|
||||
console.log(`/course/${props.banner.linkID}`);
|
||||
router.push(`/study-plan/course-detail?courseId=${props.banner.linkID}`);
|
||||
} else if (props.banner.linkType === 3) {
|
||||
window.open(props.banner.linkUrl, '_blank');
|
||||
}
|
||||
@@ -40,6 +41,10 @@ function handleLearn() {
|
||||
position: relative;
|
||||
background-color: red;
|
||||
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="currentCourseVO"
|
||||
:model="currentCourseItemVO"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
>
|
||||
@@ -18,17 +18,17 @@
|
||||
<span class="card-header">基本信息</span>
|
||||
</template>
|
||||
|
||||
<el-form-item label="课程名称" prop="course.name">
|
||||
<el-form-item label="课程名称" prop="name">
|
||||
<el-input
|
||||
id="course-name"
|
||||
v-model="currentCourseVO.course.name"
|
||||
v-model="currentCourseItemVO.name"
|
||||
placeholder="请输入课程名称"
|
||||
:disabled="!editMode"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 课程封面 - 移除 label 关联 -->
|
||||
<el-form-item prop="course.coverImage">
|
||||
<el-form-item prop="coverImage">
|
||||
<template #label>
|
||||
<span>课程封面</span>
|
||||
</template>
|
||||
@@ -36,7 +36,7 @@
|
||||
v-if="editMode"
|
||||
:as-dialog="false"
|
||||
list-type="cover"
|
||||
v-model:cover-url="currentCourseVO.course.coverImage"
|
||||
v-model:cover-url="currentCourseItemVO.coverImage"
|
||||
accept="image/*"
|
||||
:max-size="2"
|
||||
module="course"
|
||||
@@ -45,24 +45,24 @@
|
||||
@remove="handleCoverRemove"
|
||||
/>
|
||||
<div v-else class="cover-preview">
|
||||
<img v-if="currentCourseVO.course.coverImage" :src="FILE_DOWNLOAD_URL + currentCourseVO.course.coverImage" alt="课程封面" />
|
||||
<img v-if="currentCourseItemVO.coverImage" :src="FILE_DOWNLOAD_URL + currentCourseItemVO.coverImage" alt="课程封面" />
|
||||
<span v-else class="no-cover">暂无封面</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="授课老师" prop="course.teacher">
|
||||
<el-form-item label="授课老师" prop="teacher">
|
||||
<el-input
|
||||
id="course-teacher"
|
||||
v-model="currentCourseVO.course.teacher"
|
||||
v-model="currentCourseItemVO.teacher"
|
||||
placeholder="请输入授课老师"
|
||||
:disabled="!editMode"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="课程时长" prop="course.duration">
|
||||
<el-form-item label="课程时长" prop="duration">
|
||||
<el-input-number
|
||||
id="course-duration"
|
||||
v-model="currentCourseVO.course.duration"
|
||||
v-model="currentCourseItemVO.duration"
|
||||
:min="0"
|
||||
placeholder="分钟"
|
||||
:disabled="!editMode"
|
||||
@@ -70,10 +70,10 @@
|
||||
<span class="ml-2">分钟</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="课程描述" prop="course.description">
|
||||
<el-form-item label="课程描述" prop="description">
|
||||
<el-input
|
||||
id="course-description"
|
||||
v-model="currentCourseVO.course.description"
|
||||
v-model="currentCourseItemVO.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入课程描述"
|
||||
@@ -81,19 +81,19 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序号" prop="course.orderNum">
|
||||
<el-form-item label="排序号" prop="orderNum">
|
||||
<el-input-number
|
||||
id="course-orderNum"
|
||||
v-model="currentCourseVO.course.orderNum"
|
||||
v-model="currentCourseItemVO.orderNum"
|
||||
:min="0"
|
||||
:disabled="!editMode"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="course.status">
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group
|
||||
id="course-status"
|
||||
v-model="currentCourseVO.course.status"
|
||||
v-model="currentCourseItemVO.status"
|
||||
disabled
|
||||
>
|
||||
<el-radio :label="0">未上线</el-radio>
|
||||
@@ -115,19 +115,19 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="currentCourseVO.courseChapters.length === 0" class="empty-tip">
|
||||
<div v-if="!currentCourseItemVO.chapters || currentCourseItemVO.chapters.length === 0" class="empty-tip">
|
||||
暂无章节,请点击"添加章节"按钮添加
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="activeChapters">
|
||||
<el-collapse-item
|
||||
v-for="(chapterVO, chapterIndex) in currentCourseVO.courseChapters"
|
||||
v-for="(chapterVO, chapterIndex) in currentCourseItemVO.chapters"
|
||||
:key="chapterIndex"
|
||||
:name="chapterIndex"
|
||||
>
|
||||
<template #title>
|
||||
<div class="chapter-title">
|
||||
<span>章节 {{ chapterIndex + 1 }}: {{ chapterVO.chapter.name || '未命名章节' }}</span>
|
||||
<span>章节 {{ chapterIndex + 1 }}: {{ chapterVO.name || '未命名章节' }}</span>
|
||||
<div v-if="editMode" class="chapter-actions" @click.stop>
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -144,12 +144,12 @@
|
||||
<!-- 章节信息 -->
|
||||
<el-form-item
|
||||
:label="`章节${chapterIndex + 1}名称`"
|
||||
:prop="`courseChapters.${chapterIndex}.chapter.name`"
|
||||
:prop="`chapters.${chapterIndex}.name`"
|
||||
:rules="[{ required: true, message: '请输入章节名称', trigger: 'blur' }]"
|
||||
>
|
||||
<el-input
|
||||
:id="`chapter-${chapterIndex}-name`"
|
||||
v-model="chapterVO.chapter.name"
|
||||
v-model="chapterVO.name"
|
||||
placeholder="请输入章节名称"
|
||||
:disabled="!editMode"
|
||||
/>
|
||||
@@ -157,11 +157,11 @@
|
||||
|
||||
<el-form-item
|
||||
:label="`章节${chapterIndex + 1}排序`"
|
||||
:prop="`courseChapters.${chapterIndex}.chapter.orderNum`"
|
||||
:prop="`chapters.${chapterIndex}.orderNum`"
|
||||
>
|
||||
<el-input-number
|
||||
:id="`chapter-${chapterIndex}-orderNum`"
|
||||
v-model="chapterVO.chapter.orderNum"
|
||||
v-model="chapterVO.orderNum"
|
||||
:min="0"
|
||||
:disabled="!editMode"
|
||||
/>
|
||||
@@ -177,13 +177,13 @@
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="chapterVO.nodes.length === 0" class="empty-tip">
|
||||
<div v-if="!chapterVO.chapters || chapterVO.chapters.length === 0" class="empty-tip">
|
||||
暂无学习节点
|
||||
</div>
|
||||
|
||||
<div v-else class="nodes-list">
|
||||
<el-card
|
||||
v-for="(node, nodeIndex) in chapterVO.nodes"
|
||||
v-for="(node, nodeIndex) in chapterVO.chapters"
|
||||
:key="nodeIndex"
|
||||
class="node-card"
|
||||
shadow="hover"
|
||||
@@ -205,7 +205,7 @@
|
||||
|
||||
<el-form-item
|
||||
label="节点名称"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.name`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.name`"
|
||||
>
|
||||
<el-input
|
||||
:id="`node-${chapterIndex}-${nodeIndex}-name`"
|
||||
@@ -217,7 +217,7 @@
|
||||
|
||||
<el-form-item
|
||||
label="节点类型"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.nodeType`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.nodeType`"
|
||||
>
|
||||
<el-radio-group
|
||||
:id="`node-${chapterIndex}-${nodeIndex}-nodeType`"
|
||||
@@ -234,7 +234,7 @@
|
||||
<el-form-item
|
||||
v-if="node.nodeType === 0"
|
||||
label="选择文章"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.resourceID`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.resourceID`"
|
||||
>
|
||||
<el-select
|
||||
:id="`node-${chapterIndex}-${nodeIndex}-resourceID`"
|
||||
@@ -259,7 +259,7 @@
|
||||
<!-- 富文本编辑 - 移除 label 关联 -->
|
||||
<el-form-item
|
||||
v-if="node.nodeType === 1"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.content`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.content`"
|
||||
>
|
||||
<template #label>
|
||||
<span>内容编辑</span>
|
||||
@@ -270,7 +270,7 @@
|
||||
<!-- 文件上传 - 移除 label 关联 -->
|
||||
<el-form-item
|
||||
v-if="node.nodeType === 2"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.videoUrl`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.videoUrl`"
|
||||
>
|
||||
<template #label>
|
||||
<span>上传文件</span>
|
||||
@@ -295,7 +295,7 @@
|
||||
|
||||
<el-form-item
|
||||
label="节点时长"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.duration`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.duration`"
|
||||
>
|
||||
<el-input-number
|
||||
:id="`node-${chapterIndex}-${nodeIndex}-duration`"
|
||||
@@ -308,7 +308,7 @@
|
||||
|
||||
<!-- 是否必修 - 使用 template #label -->
|
||||
<el-form-item
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.isRequired`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.isRequired`"
|
||||
>
|
||||
<template #label>
|
||||
<span>是否必修</span>
|
||||
@@ -324,7 +324,7 @@
|
||||
|
||||
<el-form-item
|
||||
label="排序号"
|
||||
:prop="`courseChapters.${chapterIndex}.nodes.${nodeIndex}.orderNum`"
|
||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.orderNum`"
|
||||
>
|
||||
<el-input-number
|
||||
:id="`node-${chapterIndex}-${nodeIndex}-orderNum`"
|
||||
@@ -359,7 +359,7 @@ import { FileUpload } from '@/components/file';
|
||||
import { RichTextComponent } from '@/components/text';
|
||||
import { courseApi } from '@/apis/study';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import type { CourseNode, CourseVO, ChapterVO } from '@/types/study';
|
||||
import type { CourseItemVO } from '@/types/study';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { SysFile } from '@/types';
|
||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||
@@ -367,13 +367,6 @@ defineOptions({
|
||||
name: 'CourseAdd'
|
||||
});
|
||||
|
||||
// 节点扩展类型(用于前端交互)
|
||||
interface NodeWithExtras extends CourseNode {
|
||||
loading?: boolean;
|
||||
articleOptions?: Resource[];
|
||||
searchMethod?: (query: string) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courseID?: string;
|
||||
}
|
||||
@@ -390,26 +383,23 @@ const activeChapters = ref<number[]>([]);
|
||||
const editMode = ref(true);
|
||||
|
||||
// 原始数据(用于比对)
|
||||
const originalCourseVO = ref<CourseVO>();
|
||||
const originalCourseItemVO = ref<CourseItemVO>();
|
||||
// 当前编辑的数据
|
||||
const currentCourseVO = ref<CourseVO>({
|
||||
course: {
|
||||
name: '',
|
||||
coverImage: '',
|
||||
description: '',
|
||||
teacher: '',
|
||||
duration: 0,
|
||||
status: 0,
|
||||
orderNum: 0
|
||||
},
|
||||
courseChapters: [],
|
||||
courseTags: []
|
||||
const currentCourseItemVO = ref<CourseItemVO>({
|
||||
name: '',
|
||||
coverImage: '',
|
||||
description: '',
|
||||
teacher: '',
|
||||
duration: 0,
|
||||
status: 0,
|
||||
orderNum: 0,
|
||||
chapters: []
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
'course.name': [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
|
||||
'course.teacher': [{ required: true, message: '请输入授课老师', trigger: 'blur' }]
|
||||
'name': [{ required: true, message: '请输入课程名称', trigger: 'blur' }],
|
||||
'teacher': [{ required: true, message: '请输入授课老师', trigger: 'blur' }]
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -426,30 +416,26 @@ async function loadCourse() {
|
||||
// 确保数据结构完整,处理 null 值
|
||||
const courseData = res.data;
|
||||
|
||||
// 确保 courseChapters 是数组
|
||||
if (!courseData.courseChapters) {
|
||||
courseData.courseChapters = [];
|
||||
// 确保 chapters 是数组(章节列表)
|
||||
if (!courseData.chapters) {
|
||||
courseData.chapters = [];
|
||||
}
|
||||
|
||||
// 确保 courseTags 是数组
|
||||
if (!courseData.courseTags) {
|
||||
courseData.courseTags = [];
|
||||
}
|
||||
|
||||
// 确保每个章节的 nodes 是数组
|
||||
courseData.courseChapters.forEach((chapterVO: ChapterVO) => {
|
||||
if (!chapterVO.nodes) {
|
||||
chapterVO.nodes = [];
|
||||
// 确保每个章节的 chapters 是数组(节点列表)
|
||||
courseData.chapters.forEach((chapterVO: CourseItemVO) => {
|
||||
if (!chapterVO.chapters) {
|
||||
chapterVO.chapters = [];
|
||||
}
|
||||
});
|
||||
if (courseData.course.status === 1) {
|
||||
|
||||
if (courseData.status === 1) {
|
||||
editMode.value = false;
|
||||
}
|
||||
// 保存原始数据
|
||||
originalCourseVO.value = JSON.parse(JSON.stringify(courseData));
|
||||
originalCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
|
||||
// 设置当前编辑数据
|
||||
currentCourseVO.value = JSON.parse(JSON.stringify(courseData));
|
||||
console.log(currentCourseVO.value);
|
||||
currentCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
|
||||
console.log(currentCourseItemVO.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载课程失败:', error);
|
||||
@@ -459,21 +445,19 @@ async function loadCourse() {
|
||||
|
||||
// 添加章节
|
||||
function addChapter() {
|
||||
const newChapterVO: ChapterVO = {
|
||||
chapter: {
|
||||
chapterID: currentCourseVO.value.course.courseID,
|
||||
name: '',
|
||||
orderNum: currentCourseVO.value.courseChapters.length
|
||||
},
|
||||
nodes: []
|
||||
const newChapterVO: CourseItemVO = {
|
||||
chapterID: currentCourseItemVO.value.courseID,
|
||||
name: '',
|
||||
orderNum: currentCourseItemVO.value.chapters!.length,
|
||||
chapters: []
|
||||
};
|
||||
currentCourseVO.value.courseChapters.push(newChapterVO);
|
||||
activeChapters.value.push(currentCourseVO.value.courseChapters.length - 1);
|
||||
currentCourseItemVO.value.chapters!.push(newChapterVO);
|
||||
activeChapters.value.push(currentCourseItemVO.value.chapters!.length - 1);
|
||||
}
|
||||
|
||||
// 删除章节
|
||||
function removeChapter(index: number) {
|
||||
currentCourseVO.value.courseChapters.splice(index, 1);
|
||||
currentCourseItemVO.value.chapters!.splice(index, 1);
|
||||
// 更新激活的章节索引
|
||||
activeChapters.value = activeChapters.value
|
||||
.filter(i => i !== index)
|
||||
@@ -482,10 +466,10 @@ function removeChapter(index: number) {
|
||||
|
||||
// 添加节点
|
||||
function addNode(chapterIndex: number) {
|
||||
const nodeIndex = currentCourseVO.value.courseChapters[chapterIndex].nodes.length;
|
||||
const newNode: NodeWithExtras = {
|
||||
const nodeIndex = currentCourseItemVO.value.chapters![chapterIndex].chapters!.length;
|
||||
const newNode: CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void } = {
|
||||
nodeID: '',
|
||||
chapterID: currentCourseVO.value.courseChapters[chapterIndex].chapter.chapterID,
|
||||
chapterID: currentCourseItemVO.value.chapters![chapterIndex].chapterID,
|
||||
name: '',
|
||||
nodeType: 0,
|
||||
content: '',
|
||||
@@ -496,35 +480,37 @@ function addNode(chapterIndex: number) {
|
||||
articleOptions: [],
|
||||
searchMethod: (query: string) => searchArticles(query, chapterIndex, nodeIndex)
|
||||
};
|
||||
currentCourseVO.value.courseChapters[chapterIndex].nodes.push(newNode);
|
||||
currentCourseItemVO.value.chapters![chapterIndex].chapters!.push(newNode);
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
function removeNode(chapterIndex: number, nodeIndex: number) {
|
||||
currentCourseVO.value.courseChapters[chapterIndex].nodes.splice(nodeIndex, 1);
|
||||
currentCourseItemVO.value.chapters![chapterIndex].chapters!.splice(nodeIndex, 1);
|
||||
}
|
||||
|
||||
type NodeWithExtras = CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void };
|
||||
|
||||
// 辅助函数:获取节点的 searchMethod
|
||||
function getNodeSearchMethod(chapterIndex: number, nodeIndex: number) {
|
||||
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
|
||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||
return node.searchMethod;
|
||||
}
|
||||
|
||||
// 辅助函数:获取节点的 loading 状态
|
||||
function getNodeLoading(chapterIndex: number, nodeIndex: number) {
|
||||
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
|
||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||
return node.loading || false;
|
||||
}
|
||||
|
||||
// 辅助函数:获取节点的 articleOptions
|
||||
function getNodeArticleOptions(chapterIndex: number, nodeIndex: number) {
|
||||
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
|
||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||
return node.articleOptions || [];
|
||||
}
|
||||
|
||||
// 搜索文章
|
||||
async function searchArticles(query: string, chapterIndex: number, nodeIndex: number) {
|
||||
const node = currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex] as NodeWithExtras;
|
||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||
if (!query) {
|
||||
node.articleOptions = [];
|
||||
return;
|
||||
@@ -556,13 +542,13 @@ function handleCoverUploadSuccess(files: SysFile[]) {
|
||||
|
||||
// 处理封面删除
|
||||
function handleCoverRemove() {
|
||||
currentCourseVO.value.course.coverImage = '';
|
||||
currentCourseItemVO.value.coverImage = '';
|
||||
}
|
||||
|
||||
// 处理节点文件上传成功
|
||||
function handleNodeFileUploadSuccess(files: SysFile[], chapterIndex: number, nodeIndex: number) {
|
||||
if (files && files.length > 0) {
|
||||
currentCourseVO.value.courseChapters[chapterIndex].nodes[nodeIndex].videoUrl = files[0].filePath || '';
|
||||
currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex].videoUrl = files[0].filePath || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,16 +562,16 @@ async function handleSubmit() {
|
||||
// 先创建/更新课程
|
||||
let courseID = props.courseID;
|
||||
if (courseID) {
|
||||
const res = await courseApi.updateCourse(currentCourseVO.value);
|
||||
const res = await courseApi.updateCourse(currentCourseItemVO.value);
|
||||
if (!res.success) {
|
||||
throw new Error('更新课程失败');
|
||||
}
|
||||
} else {
|
||||
const res = await courseApi.createCourse(currentCourseVO.value);
|
||||
if (!res.success || !res.data?.course.courseID) {
|
||||
const res = await courseApi.createCourse(currentCourseItemVO.value);
|
||||
if (!res.success || !res.data?.courseID) {
|
||||
throw new Error('创建课程失败');
|
||||
}
|
||||
courseID = res.data.course.courseID;
|
||||
courseID = res.data.courseID;
|
||||
}
|
||||
ElMessage.success(props.courseID ? '课程更新成功' : '课程创建成功');
|
||||
emit('success');
|
||||
|
||||
@@ -11,151 +11,142 @@
|
||||
</div>
|
||||
|
||||
<!-- 课程详情 -->
|
||||
<div v-else-if="courseVO" class="course-content">
|
||||
<!-- 课程封面和基本信息 -->
|
||||
<div class="course-header">
|
||||
<div class="cover-section">
|
||||
<img
|
||||
:src="courseVO.course.coverImage? FILE_DOWNLOAD_URL + courseVO.course.coverImage : defaultCover"
|
||||
:alt="courseVO.course.name"
|
||||
class="cover-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h1 class="course-title">{{ courseVO.course.name }}</h1>
|
||||
|
||||
<div class="course-meta">
|
||||
<div class="meta-item">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>授课老师:{{ courseVO.course.teacher }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>课程时长:{{ formatDuration(courseVO.course.duration) }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<el-icon><View /></el-icon>
|
||||
<span>{{ courseVO.course.viewCount || 0 }} 人浏览</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>{{ courseVO.course.learnCount || 0 }} 人学习</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="course-description">
|
||||
<p>{{ courseVO.course.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleStartLearning"
|
||||
:loading="enrolling"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
{{ isEnrolled ? '继续学习' : '开始学习' }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="large"
|
||||
:icon="isCollected ? StarFilled : Star"
|
||||
@click="handleCollect"
|
||||
:type="isCollected ? 'success' : 'default'"
|
||||
>
|
||||
{{ isCollected ? '已收藏' : '收藏课程' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 学习进度 -->
|
||||
<div v-if="learningProgress" class="learning-progress">
|
||||
<div class="progress-header">
|
||||
<span>学习进度</span>
|
||||
<span class="progress-text">{{ learningProgress.progress }}%</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="learningProgress.progress"
|
||||
:stroke-width="10"
|
||||
:color="progressColor"
|
||||
<div v-else-if="courseItemVO" class="course-content">
|
||||
<!-- 课程信息看板 -->
|
||||
<div class="course-info-panel">
|
||||
<div class="panel-container">
|
||||
<!-- 左侧:课程封面 -->
|
||||
<div class="course-cover">
|
||||
<img
|
||||
:src="courseItemVO.coverImage ? FILE_DOWNLOAD_URL + courseItemVO.coverImage : defaultCover"
|
||||
:alt="courseItemVO.name"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:课程信息 -->
|
||||
<div class="course-info">
|
||||
<div class="info-content">
|
||||
<!-- 课程标题 -->
|
||||
<h1 class="course-title">{{ courseItemVO.name }}</h1>
|
||||
|
||||
<!-- 课程简介 -->
|
||||
<p class="course-desc">{{ courseItemVO.description || '暂无简介' }}</p>
|
||||
|
||||
<!-- 课程元信息 -->
|
||||
<div class="course-meta">
|
||||
<div class="meta-item">
|
||||
<el-avatar :size="24" :src="getTeacherAvatar()" />
|
||||
<span>{{ courseItemVO.teacher || '课程讲师' }}</span>
|
||||
</div>
|
||||
<div class="meta-divider"></div>
|
||||
<div class="meta-item">
|
||||
<img src="@/assets/imgs/clock.svg" alt="time" />
|
||||
<span>{{ formatDuration(courseItemVO.duration) }}</span>
|
||||
</div>
|
||||
<div class="meta-divider"></div>
|
||||
<div class="meta-item">
|
||||
<img src="@/assets/imgs/book-read.svg" alt="learning" />
|
||||
<span>{{ courseItemVO.learnCount || 0 }}人学习</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条区域 -->
|
||||
<div v-if="learningProgress" class="progress-section">
|
||||
<span class="progress-label">课程进度</span>
|
||||
<div class="progress-bar-wrapper">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: learningProgress.progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-percent">{{ learningProgress.progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleStartLearning"
|
||||
:loading="enrolling"
|
||||
>
|
||||
{{ isEnrolled ? '继续学习' : '开始学习' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
:plain="!isCollected"
|
||||
@click="handleCollect"
|
||||
>
|
||||
<el-icon><Star /></el-icon>
|
||||
收藏课程
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 课程章节 -->
|
||||
<el-card class="chapter-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>课程章节</span>
|
||||
<span class="chapter-count">共 {{ courseVO.courseChapters.length }} 章</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="chapter-section">
|
||||
<!-- 标题 -->
|
||||
<div class="section-title">
|
||||
<div class="title-bar"></div>
|
||||
<span class="title-text">课程目录</span>
|
||||
</div>
|
||||
|
||||
<div v-if="courseVO.courseChapters.length === 0" class="empty-tip">
|
||||
<!-- 章节列表 -->
|
||||
<div v-if="!courseItemVO.chapters || courseItemVO.chapters.length === 0" class="empty-tip">
|
||||
暂无章节内容
|
||||
</div>
|
||||
|
||||
<el-collapse v-else v-model="activeChapters">
|
||||
<el-collapse-item
|
||||
v-for="(chapterVO, chapterIndex) in courseVO.courseChapters"
|
||||
<div v-else class="chapter-list">
|
||||
<div
|
||||
v-for="(chapterItem, chapterIndex) in courseItemVO.chapters"
|
||||
:key="chapterIndex"
|
||||
:name="chapterIndex"
|
||||
class="chapter-item"
|
||||
>
|
||||
<template #title>
|
||||
<div class="chapter-title-bar">
|
||||
<!-- 章节标题 -->
|
||||
<div class="chapter-header" @click="toggleChapter(chapterIndex)">
|
||||
<div class="chapter-title">
|
||||
<img src="@/assets/imgs/arrow-down.svg" alt="arrow" class="chevron-icon" :class="{ 'expanded': activeChapters.includes(chapterIndex) }"/>
|
||||
<span class="chapter-name">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
章节 {{ chapterIndex + 1 }}: {{ chapterVO.chapter.name }}
|
||||
</span>
|
||||
<span class="chapter-meta">
|
||||
{{ chapterVO.nodes.length }} 个节点
|
||||
第{{ chapterIndex + 1 }}章 {{ chapterItem.name }}({{ getChapterNodes(chapterIndex).length }}小节)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 节点列表 -->
|
||||
<div v-if="chapterVO.nodes.length === 0" class="empty-tip">
|
||||
暂无学习节点
|
||||
</div>
|
||||
|
||||
<div v-else class="node-list">
|
||||
<div v-show="activeChapters.includes(chapterIndex)" class="node-list">
|
||||
<div v-if="getChapterNodes(chapterIndex).length === 0" class="empty-tip">
|
||||
暂无学习节点
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(node, nodeIndex) in chapterVO.nodes"
|
||||
v-else
|
||||
v-for="(node, nodeIndex) in getChapterNodes(chapterIndex)"
|
||||
:key="nodeIndex"
|
||||
class="node-item"
|
||||
:class="{ 'completed': isNodeCompleted(chapterIndex, nodeIndex) }"
|
||||
@click="handleNodeClick(chapterIndex, nodeIndex)"
|
||||
>
|
||||
<div class="node-info">
|
||||
<el-icon class="node-icon">
|
||||
<Document v-if="node.nodeType === 0" />
|
||||
<Edit v-else-if="node.nodeType === 1" />
|
||||
<Upload v-else />
|
||||
</el-icon>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
<el-tag
|
||||
v-if="node.isRequired === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
必修
|
||||
</el-tag>
|
||||
<div class="node-left">
|
||||
<span class="node-number">第{{ nodeIndex + 1 }}节</span>
|
||||
<div class="node-info">
|
||||
<img v-if="node.nodeType === 2" src="@/assets/imgs/video.svg" alt="video" class="node-icon" />
|
||||
<img v-else src="@/assets/imgs/article.svg" alt="article" class="node-icon" />
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-meta">
|
||||
<span v-if="node.duration" class="node-duration">
|
||||
<el-icon><Clock /></el-icon>
|
||||
{{ node.duration }} 分钟
|
||||
<div class="node-right">
|
||||
<span class="node-status">
|
||||
{{ getNodeStatusText(node, chapterIndex, nodeIndex) }}
|
||||
</span>
|
||||
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收藏按钮(底部浮动) -->
|
||||
<div class="floating-collect">
|
||||
@@ -182,24 +173,14 @@ import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
ArrowLeft,
|
||||
User,
|
||||
Clock,
|
||||
View,
|
||||
Reading,
|
||||
VideoPlay,
|
||||
Star,
|
||||
StarFilled,
|
||||
DocumentCopy,
|
||||
Document,
|
||||
Edit,
|
||||
Upload,
|
||||
ArrowRight
|
||||
StarFilled
|
||||
} from '@element-plus/icons-vue';
|
||||
import { courseApi } from '@/apis/study';
|
||||
import { learningRecordApi } from '@/apis/study';
|
||||
import { userCollectionApi } from '@/apis/usercenter';
|
||||
import { useStore } from 'vuex';
|
||||
import type { CourseVO, LearningRecord } from '@/types';
|
||||
import type { CourseItemVO, LearningRecord } from '@/types';
|
||||
import { CollectionType } from '@/types';
|
||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||
|
||||
@@ -231,20 +212,20 @@ const userInfo = computed(() => store.getters['auth/user']);
|
||||
|
||||
const loading = ref(false);
|
||||
const enrolling = ref(false);
|
||||
const courseVO = ref<CourseVO | null>(null);
|
||||
const courseItemVO = ref<CourseItemVO | null>(null);
|
||||
const isCollected = ref(false);
|
||||
const isEnrolled = ref(false);
|
||||
const learningProgress = ref<LearningRecord | null>(null);
|
||||
const activeChapters = ref<number[]>([0]); // 默认展开第一章
|
||||
const defaultCover = new URL('@/assets/imgs/article-default.png', import.meta.url).href;
|
||||
|
||||
// 进度条颜色
|
||||
const progressColor = computed(() => {
|
||||
const progress = learningProgress.value?.progress || 0;
|
||||
if (progress >= 80) return '#67c23a';
|
||||
if (progress >= 50) return '#409eff';
|
||||
return '#e6a23c';
|
||||
});
|
||||
// 辅助函数:获取章节的节点列表
|
||||
function getChapterNodes(chapterIndex: number): CourseItemVO[] {
|
||||
if (!courseItemVO.value) return [];
|
||||
const chapter = courseItemVO.value.chapters?.[chapterIndex];
|
||||
if (!chapter?.chapterID) return [];
|
||||
return courseItemVO.value.chapterNodes?.[chapter.chapterID] || [];
|
||||
}
|
||||
|
||||
watch(() => props.courseId, (newId) => {
|
||||
if (newId) {
|
||||
@@ -263,20 +244,15 @@ async function loadCourseDetail() {
|
||||
|
||||
const res = await courseApi.getCourseById(props.courseId);
|
||||
if (res.success && res.data) {
|
||||
courseVO.value = res.data;
|
||||
courseItemVO.value = res.data;
|
||||
|
||||
// 确保数据结构完整
|
||||
if (!courseVO.value.courseChapters) {
|
||||
courseVO.value.courseChapters = [];
|
||||
if (!courseItemVO.value.chapters) {
|
||||
courseItemVO.value.chapters = [];
|
||||
}
|
||||
if (!courseVO.value.courseTags) {
|
||||
courseVO.value.courseTags = [];
|
||||
if (!courseItemVO.value.chapterNodes) {
|
||||
courseItemVO.value.chapterNodes = {};
|
||||
}
|
||||
courseVO.value.courseChapters.forEach((chapter) => {
|
||||
if (!chapter.nodes) {
|
||||
chapter.nodes = [];
|
||||
}
|
||||
});
|
||||
} else {
|
||||
ElMessage.error('加载课程失败');
|
||||
}
|
||||
@@ -371,7 +347,7 @@ async function handleStartLearning() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!courseVO.value || courseVO.value.courseChapters.length === 0) {
|
||||
if (!courseItemVO.value || !courseItemVO.value.chapters || courseItemVO.value.chapters.length === 0) {
|
||||
ElMessage.warning('课程暂无内容');
|
||||
return;
|
||||
}
|
||||
@@ -418,17 +394,66 @@ function handleBack() {
|
||||
emit('back');
|
||||
}
|
||||
|
||||
// 格式化时长
|
||||
// 切换章节展开/收起
|
||||
function toggleChapter(chapterIndex: number) {
|
||||
const index = activeChapters.value.indexOf(chapterIndex);
|
||||
if (index > -1) {
|
||||
activeChapters.value.splice(index, 1);
|
||||
} else {
|
||||
activeChapters.value.push(chapterIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 判断节点是否完成
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
|
||||
// TODO: 实际应该从学习记录中判断
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取节点状态文本
|
||||
function getNodeStatusText(node: any, chapterIndex: number, nodeIndex: number): string {
|
||||
const isCompleted = isNodeCompleted(chapterIndex, nodeIndex);
|
||||
|
||||
if (isCompleted) {
|
||||
if (node.nodeType === 0) {
|
||||
// 文章类型,显示字数
|
||||
return '已学完';
|
||||
} else if (node.nodeType === 2 && node.duration) {
|
||||
// 视频类型,显示时长
|
||||
return `${node.duration}分钟|已学完`;
|
||||
}
|
||||
return '已学完';
|
||||
} else {
|
||||
if (node.nodeType === 0) {
|
||||
// 文章类型
|
||||
return '200字';
|
||||
} else if (node.nodeType === 2 && node.duration) {
|
||||
// 视频类型,显示时长和学习进度
|
||||
return `${node.duration}分钟`;
|
||||
}
|
||||
return '未学习';
|
||||
}
|
||||
}
|
||||
|
||||
// 获取讲师头像
|
||||
function getTeacherAvatar(): string {
|
||||
return 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png';
|
||||
}
|
||||
|
||||
// 图片加载失败处理
|
||||
function handleImageError(event: Event) {
|
||||
const target = event.target as HTMLImageElement;
|
||||
target.src = defaultCover;
|
||||
}
|
||||
|
||||
function formatDuration(minutes?: number): string {
|
||||
if (!minutes) return '0分钟';
|
||||
if (!minutes) return '0小时0分';
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${mins}分钟`;
|
||||
}
|
||||
return `${mins}分钟`;
|
||||
return `${hours}小时${mins}分`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -463,90 +488,208 @@ function formatDuration(minutes?: number): string {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.course-header {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
// 课程信息看板
|
||||
.course-info-panel {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
flex-shrink: 0;
|
||||
|
||||
.cover-image {
|
||||
width: 320px;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.panel-container {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
padding: 40px 60px;
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
.course-cover {
|
||||
width: 478px;
|
||||
height: 237px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.course-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
gap: 20px;
|
||||
|
||||
.meta-item {
|
||||
.info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 1.4;
|
||||
color: #141F38;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.course-desc {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1.57;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
gap: 7px;
|
||||
|
||||
.el-icon {
|
||||
color: #909399;
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
|
||||
img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1.57;
|
||||
color: #4E5969;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #F1F5F9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.course-description {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
.progress-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
.progress-label {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.71;
|
||||
color: #86909C;
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #EAEAEA;
|
||||
border-radius: 27px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #10A5A1;
|
||||
border-radius: 27px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.71;
|
||||
color: #86909C;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
gap: 27px;
|
||||
|
||||
:deep(.el-button) {
|
||||
height: 42px;
|
||||
border-radius: 8px;
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
padding: 8px 24px;
|
||||
|
||||
&.el-button--primary {
|
||||
width: 180px;
|
||||
background: #C62828;
|
||||
border-color: #C62828;
|
||||
|
||||
&:hover {
|
||||
background: #d32f2f;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
}
|
||||
|
||||
&.el-button--default {
|
||||
width: 125px;
|
||||
border-color: #86909C;
|
||||
color: #86909C;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.is-plain {
|
||||
background: #FFFFFF;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.is-plain) {
|
||||
background: #C62828;
|
||||
border-color: #C62828;
|
||||
color: #FFFFFF;
|
||||
|
||||
&:hover {
|
||||
background: #d32f2f;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.learning-progress {
|
||||
padding: 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -560,105 +703,170 @@ function formatDuration(minutes?: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-card {
|
||||
:deep(.el-card__header) {
|
||||
background: #fafafa;
|
||||
// 课程章节区域
|
||||
.chapter-section {
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 25px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.title-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: #C62828;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
.title-text {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #1E293B;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
.chapter-header {
|
||||
background: #F6F7F8;
|
||||
border-radius: 6px;
|
||||
padding: 18px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
.chapter-count {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
&:hover {
|
||||
background: #eef0f2;
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.chevron-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s;
|
||||
transform: rotate(-90deg);
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-name {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chapter-title-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-right: 20px;
|
||||
|
||||
.chapter-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chapter-meta {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
.node-list {
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
padding: 20px 30px;
|
||||
border-bottom: 1px solid #F1F5F9;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f2f5;
|
||||
transform: translateX(4px);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
.node-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
|
||||
.node-icon {
|
||||
color: #409eff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
.node-number {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.57;
|
||||
color: #C62828;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.node-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.node-duration {
|
||||
.node-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #c0c4cc;
|
||||
transition: transform 0.3s;
|
||||
gap: 6px;
|
||||
|
||||
.node-icon {
|
||||
flex-shrink: 0;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1.57;
|
||||
color: #C62828;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .arrow-icon {
|
||||
transform: translateX(4px);
|
||||
.node-right {
|
||||
.node-status {
|
||||
font-family: 'PingFang SC';
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
line-height: 1.57;
|
||||
color: #C62828;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
// 已完成的节点样式
|
||||
&.completed {
|
||||
.node-left {
|
||||
.node-number {
|
||||
color: #4E5969;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
.node-icon {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
color: #4E5969;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-right {
|
||||
.node-status {
|
||||
color: #4E5969;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<el-button @click="handleBack" :icon="ArrowLeft" text>
|
||||
{{ backButtonText }}
|
||||
</el-button>
|
||||
<span class="course-name">{{ courseVO?.course.name }}</span>
|
||||
<span class="course-name">{{ courseItemVO?.name }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="header-right">
|
||||
<span class="progress-info">
|
||||
学习进度:{{ currentProgress }}%
|
||||
@@ -26,38 +26,25 @@
|
||||
<div class="chapter-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">课程目录</span>
|
||||
<el-button
|
||||
text
|
||||
:icon="Fold"
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
<el-button text :icon="Fold" @click="toggleSidebar" />
|
||||
</div>
|
||||
|
||||
<div v-if="!sidebarCollapsed" class="chapter-list">
|
||||
<el-collapse v-model="activeChapters">
|
||||
<el-collapse-item
|
||||
v-for="(chapterVO, chapterIndex) in courseVO?.courseChapters || []"
|
||||
:key="chapterIndex"
|
||||
:name="chapterIndex"
|
||||
>
|
||||
<el-collapse-item v-for="(chapterItem, chapterIndex) in courseItemVO?.chapters || []" :key="chapterIndex"
|
||||
:name="chapterIndex">
|
||||
<template #title>
|
||||
<div class="chapter-item-title">
|
||||
<span>{{ chapterIndex + 1 }}. {{ chapterVO.chapter.name }}</span>
|
||||
<span class="chapter-count">{{ chapterVO.nodes.length }}</span>
|
||||
<span>{{ chapterIndex + 1 }}. {{ chapterItem.name }}</span>
|
||||
<span class="chapter-count">{{ getChapterNodes(chapterIndex).length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="node-items">
|
||||
<div
|
||||
v-for="(node, nodeIndex) in chapterVO.nodes"
|
||||
:key="nodeIndex"
|
||||
class="node-item-bar"
|
||||
:class="{
|
||||
active: currentChapterIndex === chapterIndex && currentNodeIndex === nodeIndex,
|
||||
completed: isNodeCompleted(chapterIndex, nodeIndex)
|
||||
}"
|
||||
@click="selectNode(chapterIndex, nodeIndex)"
|
||||
>
|
||||
<div v-for="(node, nodeIndex) in getChapterNodes(chapterIndex)" :key="nodeIndex" class="node-item-bar" :class="{
|
||||
active: currentChapterIndex === chapterIndex && currentNodeIndex === nodeIndex,
|
||||
completed: isNodeCompleted(chapterIndex, nodeIndex)
|
||||
}" @click="selectNode(chapterIndex, nodeIndex)">
|
||||
<div class="node-item-content">
|
||||
<el-icon v-if="isNodeCompleted(chapterIndex, nodeIndex)" class="check-icon">
|
||||
<CircleCheck />
|
||||
@@ -70,12 +57,7 @@
|
||||
<span class="node-item-name">{{ node.name }}</span>
|
||||
</div>
|
||||
<div class="node-item-meta">
|
||||
<el-tag
|
||||
v-if="node.isRequired === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
<el-tag v-if="node.isRequired === 1" type="danger" size="small" effect="plain">
|
||||
必修
|
||||
</el-tag>
|
||||
<span v-if="node.duration" class="node-duration">
|
||||
@@ -90,12 +72,8 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧:学习内容区 -->
|
||||
<div
|
||||
ref="contentAreaRef"
|
||||
class="content-area"
|
||||
:class="{ expanded: sidebarCollapsed }"
|
||||
@scroll="handleContentScroll"
|
||||
>
|
||||
<div ref="contentAreaRef" class="content-area" :class="{ expanded: sidebarCollapsed }"
|
||||
@scroll="handleContentScroll">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<el-skeleton :rows="8" animated />
|
||||
@@ -110,7 +88,9 @@
|
||||
必修
|
||||
</el-tag>
|
||||
<span v-if="currentNode.duration" class="duration">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<el-icon>
|
||||
<Clock />
|
||||
</el-icon>
|
||||
{{ currentNode.duration }} 分钟
|
||||
</span>
|
||||
</div>
|
||||
@@ -118,11 +98,7 @@
|
||||
|
||||
<!-- 文章资源 -->
|
||||
<div v-if="currentNode.nodeType === 0 && articleData" class="article-content">
|
||||
<ArticleShowView
|
||||
:as-dialog="false"
|
||||
:article-data="articleData"
|
||||
:category-list="[]"
|
||||
/>
|
||||
<ArticleShowView :as-dialog="false" :article-data="articleData" :category-list="[]" />
|
||||
</div>
|
||||
|
||||
<!-- 富文本内容 -->
|
||||
@@ -132,32 +108,26 @@
|
||||
<!-- 文件内容 -->
|
||||
<div v-else-if="currentNode.nodeType === 2" class="file-content">
|
||||
<div v-if="isVideoFile(currentNode.videoUrl)" class="video-wrapper">
|
||||
<video
|
||||
:src="getFileUrl(currentNode.videoUrl)"
|
||||
controls
|
||||
class="video-player"
|
||||
@timeupdate="handleVideoProgress"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<video :src="getFileUrl(currentNode.videoUrl)" controls class="video-player"
|
||||
@timeupdate="handleVideoProgress" @ended="handleVideoEnded">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
<div v-else-if="isAudioFile(currentNode.videoUrl)" class="audio-wrapper">
|
||||
<audio
|
||||
:src="getFileUrl(currentNode.videoUrl)"
|
||||
controls
|
||||
class="audio-player"
|
||||
@timeupdate="handleAudioProgress"
|
||||
@ended="handleAudioEnded"
|
||||
>
|
||||
<audio :src="getFileUrl(currentNode.videoUrl)" controls class="audio-player"
|
||||
@timeupdate="handleAudioProgress" @ended="handleAudioEnded">
|
||||
您的浏览器不支持音频播放
|
||||
</audio>
|
||||
</div>
|
||||
<div v-else class="file-download">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<el-icon class="file-icon">
|
||||
<Document />
|
||||
</el-icon>
|
||||
<p>文件资源</p>
|
||||
<el-button type="primary" @click="downloadFile(currentNode.videoUrl)">
|
||||
<el-icon><Download /></el-icon>
|
||||
<el-icon>
|
||||
<Download />
|
||||
</el-icon>
|
||||
下载文件
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -165,30 +135,23 @@
|
||||
|
||||
<!-- 学习操作 -->
|
||||
<div class="learning-actions">
|
||||
<el-button
|
||||
@click="markAsComplete"
|
||||
:type="isCurrentNodeCompleted ? 'success' : 'primary'"
|
||||
:disabled="isCurrentNodeCompleted"
|
||||
>
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<el-button @click="markAsComplete" :type="isCurrentNodeCompleted ? 'success' : 'primary'"
|
||||
:disabled="isCurrentNodeCompleted">
|
||||
<el-icon>
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
{{ isCurrentNodeCompleted ? '已完成' : '标记为完成' }}
|
||||
</el-button>
|
||||
|
||||
<div class="navigation-buttons">
|
||||
<el-button
|
||||
@click="gotoPrevious"
|
||||
:disabled="!hasPrevious"
|
||||
:icon="ArrowLeft"
|
||||
>
|
||||
<el-button @click="gotoPrevious" :disabled="!hasPrevious" :icon="ArrowLeft">
|
||||
上一节
|
||||
</el-button>
|
||||
<el-button
|
||||
@click="gotoNext"
|
||||
:disabled="!hasNext"
|
||||
type="primary"
|
||||
>
|
||||
<el-button @click="gotoNext" :disabled="!hasNext" type="primary">
|
||||
下一节
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
<el-icon>
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +164,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -225,7 +189,7 @@ import { learningRecordApi, learningHistoryApi } from '@/apis/study';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { useStore } from 'vuex';
|
||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||
import type { CourseVO, LearningRecord, TbLearningHistory } from '@/types';
|
||||
import type { CourseItemVO, LearningRecord, TbLearningHistory } from '@/types';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -237,7 +201,7 @@ interface Props {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chapterIndex: 0,
|
||||
nodeIndex: 0,
|
||||
backButtonText: '返回课程详情'
|
||||
backButtonText: '返回'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -249,7 +213,7 @@ const userInfo = computed(() => store.getters['auth/user']);
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const courseVO = ref<CourseVO | null>(null);
|
||||
const courseItemVO = ref<CourseItemVO | null>(null);
|
||||
const currentChapterIndex = ref(0);
|
||||
const currentNodeIndex = ref(0);
|
||||
const sidebarCollapsed = ref(false);
|
||||
@@ -274,32 +238,37 @@ const learningHistory = ref<TbLearningHistory | null>(null);
|
||||
const historyStartTime = ref<number>(0);
|
||||
const historyTimer = ref<number | null>(null);
|
||||
|
||||
// 辅助函数:获取章节的节点列表
|
||||
function getChapterNodes(chapterIndex: number): CourseItemVO[] {
|
||||
if (!courseItemVO.value) return [];
|
||||
const chapter = courseItemVO.value.chapters?.[chapterIndex];
|
||||
if (!chapter?.chapterID) return [];
|
||||
return courseItemVO.value.chapterNodes?.[chapter.chapterID] || [];
|
||||
}
|
||||
|
||||
// 当前节点
|
||||
const currentNode = computed(() => {
|
||||
if (!courseVO.value || !courseVO.value.courseChapters) return null;
|
||||
|
||||
const chapter = courseVO.value.courseChapters[currentChapterIndex.value];
|
||||
if (!chapter || !chapter.nodes) return null;
|
||||
|
||||
return chapter.nodes[currentNodeIndex.value] || null;
|
||||
const nodes = getChapterNodes(currentChapterIndex.value);
|
||||
return nodes[currentNodeIndex.value] || null;
|
||||
});
|
||||
|
||||
// 当前进度
|
||||
const currentProgress = computed(() => {
|
||||
if (!courseVO.value || !courseVO.value.courseChapters) return 0;
|
||||
|
||||
if (!courseItemVO.value || !courseItemVO.value.chapters) return 0;
|
||||
|
||||
let totalNodes = 0;
|
||||
let completedCount = 0;
|
||||
|
||||
courseVO.value.courseChapters.forEach((chapter, chapterIdx) => {
|
||||
chapter.nodes.forEach((node, nodeIdx) => {
|
||||
|
||||
courseItemVO.value.chapters.forEach((chapter, chapterIdx) => {
|
||||
const nodes = getChapterNodes(chapterIdx);
|
||||
nodes.forEach((node, nodeIdx) => {
|
||||
totalNodes++;
|
||||
if (isNodeCompleted(chapterIdx, nodeIdx)) {
|
||||
completedCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
if (totalNodes === 0) return 0;
|
||||
return Math.round((completedCount / totalNodes) * 100);
|
||||
});
|
||||
@@ -314,23 +283,21 @@ const hasPrevious = computed(() => {
|
||||
|
||||
// 是否有下一节
|
||||
const hasNext = computed(() => {
|
||||
if (!courseVO.value || !courseVO.value.courseChapters) return false;
|
||||
|
||||
const chapters = courseVO.value.courseChapters;
|
||||
const currentChapter = chapters[currentChapterIndex.value];
|
||||
|
||||
if (!currentChapter) return false;
|
||||
|
||||
if (!courseItemVO.value || !courseItemVO.value.chapters) return false;
|
||||
|
||||
const chapters = courseItemVO.value.chapters;
|
||||
const currentChapterNodes = getChapterNodes(currentChapterIndex.value);
|
||||
|
||||
// 不是当前章节的最后一个节点
|
||||
if (currentNodeIndex.value < currentChapter.nodes.length - 1) {
|
||||
if (currentNodeIndex.value < currentChapterNodes.length - 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// 不是最后一章
|
||||
if (currentChapterIndex.value < chapters.length - 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -357,10 +324,10 @@ watch(currentNode, async () => {
|
||||
await saveHistoryRecord();
|
||||
stopHistoryTimer();
|
||||
}
|
||||
|
||||
|
||||
if (currentNode.value) {
|
||||
loadNodeContent();
|
||||
|
||||
|
||||
// 为新节点创建学习历史记录
|
||||
await createHistoryRecord();
|
||||
}
|
||||
@@ -373,7 +340,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
stopLearningTimer();
|
||||
saveLearningProgress();
|
||||
|
||||
|
||||
// 保存学习历史记录
|
||||
if (learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
@@ -385,20 +352,18 @@ onBeforeUnmount(() => {
|
||||
async function loadCourse() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await courseApi.getCourseById(props.courseId);
|
||||
const res = await courseApi.getCourseProgress(props.courseId);
|
||||
if (res.success && res.data) {
|
||||
courseVO.value = res.data;
|
||||
|
||||
courseItemVO.value = res.data;
|
||||
|
||||
// 确保数据结构完整
|
||||
if (!courseVO.value.courseChapters) {
|
||||
courseVO.value.courseChapters = [];
|
||||
if (!courseItemVO.value.chapters) {
|
||||
courseItemVO.value.chapters = [];
|
||||
}
|
||||
courseVO.value.courseChapters.forEach((chapter) => {
|
||||
if (!chapter.nodes) {
|
||||
chapter.nodes = [];
|
||||
}
|
||||
});
|
||||
|
||||
if (!courseItemVO.value.chapterNodes) {
|
||||
courseItemVO.value.chapterNodes = {};
|
||||
}
|
||||
|
||||
// 加载学习记录
|
||||
await loadLearningRecord();
|
||||
}
|
||||
@@ -413,17 +378,17 @@ async function loadCourse() {
|
||||
// 加载学习记录
|
||||
async function loadLearningRecord() {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
|
||||
try {
|
||||
const res = await learningRecordApi.getCourseLearningRecord({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 2, // 课程
|
||||
courseID: props.courseId
|
||||
});
|
||||
|
||||
|
||||
if (res.success && res.dataList && res.dataList.length > 0) {
|
||||
learningRecord.value = res.dataList[0];
|
||||
|
||||
|
||||
// 从本地存储加载已完成的节点列表
|
||||
const savedProgress = localStorage.getItem(`course_${props.courseId}_nodes`);
|
||||
if (savedProgress) {
|
||||
@@ -441,17 +406,22 @@ async function loadLearningRecord() {
|
||||
// 创建学习记录
|
||||
async function createLearningRecord() {
|
||||
if (!userInfo.value?.id) return;
|
||||
|
||||
|
||||
try {
|
||||
const currentChapter = courseItemVO.value?.chapters?.[currentChapterIndex.value];
|
||||
const currentNodeData = currentChapter?.chapterID ?
|
||||
courseItemVO.value?.chapterNodes?.[currentChapter.chapterID]?.[currentNodeIndex.value] :
|
||||
null;
|
||||
|
||||
const res = await learningRecordApi.createRecord({
|
||||
userID: userInfo.value.id,
|
||||
resourceType: 2, // 课程
|
||||
courseID: props.courseId,
|
||||
chapterID: courseVO.value?.courseChapters[currentChapterIndex.value].chapter.chapterID,
|
||||
nodeID: courseVO.value?.courseChapters[currentChapterIndex.value].nodes[currentNodeIndex.value].nodeID,
|
||||
chapterID: currentChapter?.chapterID,
|
||||
nodeID: currentNodeData?.nodeID,
|
||||
taskID: route.query.taskId as string
|
||||
});
|
||||
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningRecord.value = res.data;
|
||||
console.log('学习记录创建成功');
|
||||
@@ -464,7 +434,7 @@ async function createLearningRecord() {
|
||||
// 加载节点内容
|
||||
async function loadNodeContent() {
|
||||
if (!currentNode.value) return;
|
||||
|
||||
|
||||
// 如果是文章资源,加载文章内容
|
||||
if (currentNode.value.nodeType === 0 && currentNode.value.resourceID) {
|
||||
try {
|
||||
@@ -481,7 +451,7 @@ async function loadNodeContent() {
|
||||
} else {
|
||||
articleData.value = null;
|
||||
}
|
||||
|
||||
|
||||
// 展开当前章节
|
||||
if (!activeChapters.value.includes(currentChapterIndex.value)) {
|
||||
activeChapters.value.push(currentChapterIndex.value);
|
||||
@@ -494,20 +464,20 @@ async function selectNode(chapterIndex: number, nodeIndex: number) {
|
||||
if (previousNodeKey.value && (hasScrolledToBottom.value || !hasScrollbar())) {
|
||||
await markNodeComplete(previousNodeKey.value);
|
||||
}
|
||||
|
||||
|
||||
// 切换到新节点
|
||||
currentChapterIndex.value = chapterIndex;
|
||||
currentNodeIndex.value = nodeIndex;
|
||||
|
||||
|
||||
// 重置滚动状态
|
||||
hasScrolledToBottom.value = false;
|
||||
previousNodeKey.value = `${chapterIndex}-${nodeIndex}`;
|
||||
|
||||
|
||||
// 滚动到顶部
|
||||
if (contentAreaRef.value) {
|
||||
contentAreaRef.value.scrollTop = 0;
|
||||
}
|
||||
|
||||
|
||||
// 等待 DOM 更新后初始化视频监听
|
||||
await nextTick();
|
||||
initRichTextVideoListeners();
|
||||
@@ -515,25 +485,25 @@ async function selectNode(chapterIndex: number, nodeIndex: number) {
|
||||
|
||||
// 上一节
|
||||
function gotoPrevious() {
|
||||
if (!hasPrevious.value || !courseVO.value) return;
|
||||
|
||||
if (!hasPrevious.value || !courseItemVO.value) return;
|
||||
|
||||
if (currentNodeIndex.value > 0) {
|
||||
currentNodeIndex.value--;
|
||||
} else {
|
||||
// 跳到上一章的最后一节
|
||||
currentChapterIndex.value--;
|
||||
const prevChapter = courseVO.value.courseChapters[currentChapterIndex.value];
|
||||
currentNodeIndex.value = prevChapter.nodes.length - 1;
|
||||
const prevChapterNodes = getChapterNodes(currentChapterIndex.value);
|
||||
currentNodeIndex.value = prevChapterNodes.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 下一节
|
||||
function gotoNext() {
|
||||
if (!hasNext.value || !courseVO.value) return;
|
||||
|
||||
const currentChapter = courseVO.value.courseChapters[currentChapterIndex.value];
|
||||
|
||||
if (currentNodeIndex.value < currentChapter.nodes.length - 1) {
|
||||
if (!hasNext.value || !courseItemVO.value) return;
|
||||
|
||||
const currentChapterNodes = getChapterNodes(currentChapterIndex.value);
|
||||
|
||||
if (currentNodeIndex.value < currentChapterNodes.length - 1) {
|
||||
currentNodeIndex.value++;
|
||||
} else {
|
||||
// 跳到下一章的第一节
|
||||
@@ -545,12 +515,12 @@ function gotoNext() {
|
||||
// 标记为完成
|
||||
async function markAsComplete() {
|
||||
if (!currentNode.value) return;
|
||||
|
||||
|
||||
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
||||
await markNodeComplete(nodeKey);
|
||||
|
||||
|
||||
ElMessage.success('已标记为完成');
|
||||
|
||||
|
||||
// 自动跳转到下一节
|
||||
if (hasNext.value) {
|
||||
setTimeout(() => {
|
||||
@@ -568,7 +538,7 @@ function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
|
||||
// 开始学习计时
|
||||
function startLearningTimer() {
|
||||
learningStartTime.value = Date.now();
|
||||
|
||||
|
||||
// 每10秒保存一次学习进度(如果未完成)
|
||||
learningTimer.value = window.setInterval(() => {
|
||||
// 如果课程已完成,停止定时器
|
||||
@@ -591,16 +561,16 @@ function stopLearningTimer() {
|
||||
// 保存学习进度
|
||||
async function saveLearningProgress() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
|
||||
// 如果课程已完成,不再保存进度
|
||||
if (learningRecord.value.isComplete) {
|
||||
console.log('课程已完成,跳过进度保存');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - learningStartTime.value) / 1000); // 秒
|
||||
|
||||
|
||||
try {
|
||||
const updatedRecord = {
|
||||
id: learningRecord.value.id,
|
||||
@@ -611,14 +581,14 @@ async function saveLearningProgress() {
|
||||
progress: currentProgress.value,
|
||||
isComplete: currentProgress.value === 100
|
||||
};
|
||||
|
||||
|
||||
await learningRecordApi.updateRecord(updatedRecord);
|
||||
|
||||
|
||||
// 更新本地记录
|
||||
learningRecord.value.duration = updatedRecord.duration;
|
||||
learningRecord.value.progress = updatedRecord.progress;
|
||||
learningRecord.value.isComplete = updatedRecord.isComplete;
|
||||
|
||||
|
||||
// 重置开始时间
|
||||
learningStartTime.value = currentTime;
|
||||
} catch (error) {
|
||||
@@ -629,24 +599,24 @@ async function saveLearningProgress() {
|
||||
// 标记节点完成
|
||||
async function markNodeComplete(nodeKey: string) {
|
||||
if (completedNodes.value.has(nodeKey)) return;
|
||||
|
||||
|
||||
// 如果课程已完成,不再标记节点
|
||||
if (learningRecord.value?.isComplete) {
|
||||
console.log('课程已完成,跳过节点标记');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
completedNodes.value.add(nodeKey);
|
||||
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem(
|
||||
`course_${props.courseId}_nodes`,
|
||||
JSON.stringify(Array.from(completedNodes.value))
|
||||
);
|
||||
|
||||
|
||||
// 更新学习进度
|
||||
await saveLearningProgress();
|
||||
|
||||
|
||||
// 如果全部完成,标记课程为完成
|
||||
if (currentProgress.value === 100) {
|
||||
await markCourseComplete();
|
||||
@@ -656,7 +626,7 @@ async function markNodeComplete(nodeKey: string) {
|
||||
// 标记课程完成
|
||||
async function markCourseComplete() {
|
||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||
|
||||
|
||||
try {
|
||||
await learningRecordApi.markComplete({
|
||||
id: learningRecord.value.id,
|
||||
@@ -667,7 +637,7 @@ async function markCourseComplete() {
|
||||
progress: 100,
|
||||
isComplete: true
|
||||
});
|
||||
|
||||
|
||||
ElMessage.success('恭喜你完成了整个课程!');
|
||||
} catch (error) {
|
||||
console.error('标记课程完成失败:', error);
|
||||
@@ -686,7 +656,7 @@ function handleContentScroll(event: Event) {
|
||||
const scrollTop = target.scrollTop;
|
||||
const scrollHeight = target.scrollHeight;
|
||||
const clientHeight = target.clientHeight;
|
||||
|
||||
|
||||
// 判断是否滚动到底部(留10px容差)
|
||||
if (scrollHeight - scrollTop - clientHeight < 10) {
|
||||
if (!hasScrolledToBottom.value) {
|
||||
@@ -703,7 +673,7 @@ function handleVideoProgress(event: Event) {
|
||||
// 可以在这里记录视频播放进度
|
||||
const video = event.target as HTMLVideoElement;
|
||||
const progress = (video.currentTime / video.duration) * 100;
|
||||
|
||||
|
||||
// 如果播放超过80%,自动标记为完成
|
||||
if (progress > 80 && !isCurrentNodeCompleted.value) {
|
||||
// markAsComplete();
|
||||
@@ -727,30 +697,30 @@ function initRichTextVideoListeners() {
|
||||
'.content-area .rich-text-content',
|
||||
'.rich-text-content'
|
||||
];
|
||||
|
||||
|
||||
let richTextContent: Element | null = null;
|
||||
|
||||
|
||||
for (const selector of selectors) {
|
||||
richTextContent = document.querySelector(selector);
|
||||
if (richTextContent) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!richTextContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const videos = richTextContent.querySelectorAll('video');
|
||||
|
||||
|
||||
if (videos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 初始化视频数量和完成状态
|
||||
totalRichTextVideos.value = videos.length;
|
||||
completedRichTextVideos.value.clear();
|
||||
|
||||
|
||||
// 监听所有视频的播放结束事件
|
||||
videos.forEach((video, index) => {
|
||||
const videoElement = video as HTMLVideoElement;
|
||||
@@ -766,9 +736,9 @@ function initRichTextVideoListeners() {
|
||||
function handleRichTextVideoEnded(videoIndex: number) {
|
||||
// 标记该视频已完成
|
||||
completedRichTextVideos.value.add(videoIndex);
|
||||
|
||||
|
||||
const completedCount = completedRichTextVideos.value.size;
|
||||
|
||||
|
||||
// 检查是否所有视频都已完成
|
||||
if (completedCount >= totalRichTextVideos.value) {
|
||||
if (!isCurrentNodeCompleted.value) {
|
||||
@@ -782,7 +752,7 @@ function handleRichTextVideoEnded(videoIndex: number) {
|
||||
function handleAudioProgress(event: Event) {
|
||||
const audio = event.target as HTMLAudioElement;
|
||||
const progress = (audio.currentTime / audio.duration) * 100;
|
||||
|
||||
|
||||
if (progress > 80 && !isCurrentNodeCompleted.value) {
|
||||
// markAsComplete();
|
||||
}
|
||||
@@ -831,13 +801,13 @@ function toggleSidebar() {
|
||||
|
||||
// 创建学习历史记录
|
||||
async function createHistoryRecord() {
|
||||
if (!userInfo.value?.id || !courseVO.value || !currentNode.value) return;
|
||||
|
||||
if (!userInfo.value?.id || !courseItemVO.value || !currentNode.value) return;
|
||||
|
||||
try {
|
||||
const chapterVO = courseVO.value.courseChapters[currentChapterIndex.value];
|
||||
const chapter = chapterVO?.chapter;
|
||||
const chapterItem = courseItemVO.value.chapters![currentChapterIndex.value];
|
||||
const chapter = chapterItem;
|
||||
const node = currentNode.value;
|
||||
|
||||
|
||||
const res = await learningHistoryApi.recordCourseLearn(
|
||||
userInfo.value.id,
|
||||
props.courseId,
|
||||
@@ -845,11 +815,11 @@ async function createHistoryRecord() {
|
||||
node.nodeID,
|
||||
0 // 初始时长为0
|
||||
);
|
||||
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log('✅ 课程学习历史记录创建成功:', learningHistory.value);
|
||||
|
||||
|
||||
// 开始计时
|
||||
startHistoryTimer();
|
||||
}
|
||||
@@ -861,7 +831,7 @@ async function createHistoryRecord() {
|
||||
// 开始学习历史计时
|
||||
function startHistoryTimer() {
|
||||
historyStartTime.value = Date.now();
|
||||
|
||||
|
||||
// 每30秒保存一次学习历史
|
||||
historyTimer.value = window.setInterval(() => {
|
||||
saveHistoryRecord();
|
||||
@@ -879,28 +849,28 @@ function stopHistoryTimer() {
|
||||
// 保存学习历史记录
|
||||
async function saveHistoryRecord() {
|
||||
if (!userInfo.value?.id || !learningHistory.value) return;
|
||||
|
||||
|
||||
const currentTime = Date.now();
|
||||
const duration = Math.floor((currentTime - historyStartTime.value) / 1000); // 秒
|
||||
|
||||
|
||||
// 如果时长太短(小于1秒),不保存
|
||||
if (duration < 1) return;
|
||||
|
||||
|
||||
try {
|
||||
const updatedHistory: TbLearningHistory = {
|
||||
...learningHistory.value,
|
||||
duration: (learningHistory.value.duration || 0) + duration,
|
||||
endTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
// 调用API更新学习历史
|
||||
const res = await learningHistoryApi.recordLearningHistory(updatedHistory);
|
||||
|
||||
|
||||
if (res.success && res.data) {
|
||||
learningHistory.value = res.data;
|
||||
console.log(`💾 课程学习历史已保存 - 累计时长: ${learningHistory.value.duration}秒 - 节点: ${currentNode.value?.name}`);
|
||||
}
|
||||
|
||||
|
||||
// 重置开始时间
|
||||
historyStartTime.value = currentTime;
|
||||
} catch (error) {
|
||||
@@ -911,13 +881,13 @@ async function saveHistoryRecord() {
|
||||
function handleBack() {
|
||||
stopLearningTimer();
|
||||
saveLearningProgress();
|
||||
|
||||
|
||||
// 保存学习历史记录
|
||||
if (learningHistory.value) {
|
||||
saveHistoryRecord();
|
||||
}
|
||||
stopHistoryTimer();
|
||||
|
||||
|
||||
emit('back');
|
||||
}
|
||||
</script>
|
||||
@@ -941,19 +911,19 @@ function handleBack() {
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
|
||||
.course-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.header-right {
|
||||
.progress-info {
|
||||
font-size: 14px;
|
||||
@@ -974,7 +944,7 @@ function handleBack() {
|
||||
left: 16px;
|
||||
top: 16px;
|
||||
z-index: 10;
|
||||
|
||||
|
||||
:deep(.el-button) {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -986,26 +956,26 @@ function handleBack() {
|
||||
border-right: 1px solid #e4e7ed;
|
||||
overflow-y: auto;
|
||||
transition: width 0.3s;
|
||||
|
||||
|
||||
&.collapsed {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.chapter-list {
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -1019,7 +989,7 @@ function handleBack() {
|
||||
padding-right: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
|
||||
|
||||
.chapter-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
@@ -1042,48 +1012,48 @@ function handleBack() {
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
|
||||
&.completed {
|
||||
.check-icon {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.node-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
|
||||
.node-type-icon {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
|
||||
.check-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.node-item-name {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.node-item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
.node-duration {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
@@ -1095,7 +1065,7 @@ function handleBack() {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
|
||||
|
||||
&.expanded {
|
||||
margin-left: 0;
|
||||
}
|
||||
@@ -1115,19 +1085,19 @@ function handleBack() {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
|
||||
|
||||
.node-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
|
||||
.node-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
|
||||
.duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1150,7 +1120,7 @@ function handleBack() {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
:deep(video) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@@ -1159,7 +1129,7 @@ function handleBack() {
|
||||
|
||||
.file-content {
|
||||
margin-bottom: 24px;
|
||||
|
||||
|
||||
.video-wrapper,
|
||||
.audio-wrapper {
|
||||
display: flex;
|
||||
@@ -1167,17 +1137,17 @@ function handleBack() {
|
||||
padding: 24px;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
|
||||
|
||||
.video-player {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.file-download {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1186,13 +1156,13 @@ function handleBack() {
|
||||
padding: 60px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
|
||||
|
||||
.file-icon {
|
||||
font-size: 64px;
|
||||
color: #909399;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
color: #606266;
|
||||
@@ -1206,7 +1176,7 @@ function handleBack() {
|
||||
align-items: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
|
||||
|
||||
.navigation-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -1221,4 +1191,3 @@ function handleBack() {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@ onMounted(() => {
|
||||
async function loadCourses() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await courseApi.getCoursePage({page: currentPage.value, size: pageSize.value}, searchForm);
|
||||
const res = await courseApi.getCoursePage({pageNumber: currentPage.value, pageSize: pageSize.value}, searchForm);
|
||||
if (res.success && res.pageDomain) {
|
||||
courseList.value = res.pageDomain.dataList || [];
|
||||
total.value = res.pageDomain.pageParam.totalElements || 0;
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
<div class="home-view">
|
||||
<!-- 轮播横幅区域 -->
|
||||
<div class="banner-section">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="banner-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 轮播图 -->
|
||||
<Carousel
|
||||
v-else-if="banners.length > 0"
|
||||
:items="banners"
|
||||
:interval="5000"
|
||||
:active-icon="dangIcon"
|
||||
@@ -12,6 +20,11 @@
|
||||
<BannerCard :banner="item" />
|
||||
</template>
|
||||
</Carousel>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="banner-empty">
|
||||
<p>暂无轮播内容</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 热门资源推荐 -->
|
||||
@@ -57,18 +70,44 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { BannerCard, LearningProgress } from '@/views/public/';
|
||||
import { HotArticleCard, IdeologicalArticleCard } from '@/views/public/article';
|
||||
import { Carousel } from '@/components/base';
|
||||
import { ArrowRight } from '@element-plus/icons-vue';
|
||||
import { bannerApi } from '@/apis/resource/banner';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import type { Banner } from '@/types';
|
||||
import dangIcon from '@/assets/imgs/dang.svg';
|
||||
|
||||
// 模拟轮播数据,实际应该从接口获取
|
||||
const banners = ref([
|
||||
{ id: 1, imageUrl: '', linkType: 1, linkID: '', linkUrl: '' },
|
||||
{ id: 2, imageUrl: '', linkType: 1, linkID: '', linkUrl: '' },
|
||||
]);
|
||||
// 轮播数据
|
||||
const banners = ref<Banner[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// 加载轮播图数据
|
||||
async function loadBanners() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await bannerApi.getHomeBannerList();
|
||||
|
||||
if (result.code === 200 && result.dataList) {
|
||||
// 只显示启用状态的banner,按排序号排序
|
||||
banners.value = result.dataList
|
||||
.filter((banner: Banner) => banner.status === 1)
|
||||
.sort((a: Banner, b: Banner) => (a.orderNum || 0) - (b.orderNum || 0));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载轮播图失败:', error);
|
||||
ElMessage.error('加载轮播图失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadBanners();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -81,6 +120,37 @@ const banners = ref([
|
||||
.banner-section {
|
||||
width: 100%;
|
||||
height: 30vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner-loading,
|
||||
.banner-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f7fa;
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e4e7ed;
|
||||
border-top-color: #E7000B;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.section {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@category-change="handleCategoryChange"
|
||||
/>
|
||||
<ResourceList
|
||||
v-if="!showArticle"
|
||||
v-show="!showArticle"
|
||||
ref="resourceListRef"
|
||||
:tagID="currentCategoryId"
|
||||
:search-keyword="searchKeyword"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div class="resource-article">
|
||||
<ArticleShowView
|
||||
<ArticleShow
|
||||
v-if="articleData"
|
||||
:as-dialog="false"
|
||||
:article-data="articleData"
|
||||
:category-list="[]"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回列表"
|
||||
@back="handleBack"
|
||||
@@ -24,7 +23,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ArticleShowView } from '@/views/public/article';
|
||||
import { ArticleShow } from '@/views/public/article';
|
||||
import { ResouceCollect, ResouceBottom } from '@/views/user/resource-center/components';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
@@ -3,16 +3,17 @@
|
||||
v-if="courseId"
|
||||
:course-id="courseId"
|
||||
:show-back-button="true"
|
||||
back-button-text="返回课程列表"
|
||||
back-button-text="返回"
|
||||
@back="handleBack"
|
||||
@start-learning="handleStartLearning"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { CourseDetail } from '@/views/public/course/components';
|
||||
import { courseApi } from '@/apis/study';
|
||||
|
||||
defineOptions({
|
||||
name: 'CourseDetailView'
|
||||
@@ -27,6 +28,10 @@ const courseId = computed(() => route.query.courseId as string || '');
|
||||
function handleBack() {
|
||||
router.back();
|
||||
}
|
||||
onMounted(() => {
|
||||
// 调用接口更新浏览记录
|
||||
courseApi.incrementViewCount(courseId.value);
|
||||
});
|
||||
|
||||
// 开始学习课程
|
||||
function handleStartLearning(courseId: string, chapterIndex: number, nodeIndex: number) {
|
||||
|
||||