Browse Source

修复个人信息管理的证书详情

master
ChaiNingQi 1 month ago
parent
commit
13fc2a6fd6
  1. 142
      chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetCertificateController.java
  2. 87
      chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetExperienceArticleController.java
  3. 19
      chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetTrainingVideoController.java
  4. 4
      chenhai-admin/src/main/resources/application.yml
  5. 14
      chenhai-system/src/main/java/com/chenhai/vet/domain/VetExperienceArticle.java
  6. 36
      chenhai-system/src/main/java/com/chenhai/vet/mapper/VetArticleCategoryMapper.java
  7. 5
      chenhai-system/src/main/java/com/chenhai/vet/mapper/VetExperienceArticleMapper.java
  8. 2
      chenhai-system/src/main/java/com/chenhai/vet/service/IVetExperienceArticleService.java
  9. 14
      chenhai-system/src/main/java/com/chenhai/vet/service/impl/VetExperienceArticleServiceImpl.java
  10. 34
      chenhai-system/src/main/resources/mapper/vet/VetArticleCategoryMapper.xml
  11. 22
      chenhai-system/src/main/resources/mapper/vet/VetExperienceArticleMapper.xml
  12. 11
      chenhai-system/src/main/resources/mapper/vet/VetTrainingVideoMapper.xml
  13. 17
      chenhai-ui/src/api/vet/training.js
  14. 578
      chenhai-ui/src/views/vet/training/TrainingHome.vue

142
chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetCertificateController.java

@ -11,7 +11,9 @@ import com.chenhai.vet.domain.VetCertificate;
import com.chenhai.vet.service.IVetCertificateService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.CustomNumberEditor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@ -30,6 +32,35 @@ public class VetCertificateController extends BaseController
@Autowired
private IVetCertificateService vetCertificateService;
/**
* 初始化数据绑定处理空字符串转换问题
* 防止前端传递空字符串导致Long类型绑定失败
*/
@InitBinder
public void initBinder(WebDataBinder binder) {
// 处理Long类型字段的空字符串问题
binder.registerCustomEditor(Long.class, new CustomNumberEditor(Long.class, true) {
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (text == null || text.trim().isEmpty()) {
setValue(null); // 将空字符串转换为null
} else {
try {
setValue(Long.parseLong(text.trim()));
} catch (NumberFormatException e) {
setValue(null); // 转换失败也设为null
}
}
}
@Override
public String getAsText() {
Object value = getValue();
return (value != null ? value.toString() : "");
}
});
}
/**
* 获取当前用户ID
*/
@ -61,9 +92,10 @@ public class VetCertificateController extends BaseController
return certificate != null && currentUserId.equals(certificate.getUserId());
}
/**
* 查询兽医执业证书列表
* 查询兽医执业证书列表证书管理页面使用
* 管理员查看所有证书
* 普通用户查看自己的证书
*/
@PreAuthorize("@ss.hasPermi('vet:certificate:list')")
@GetMapping("/list")
@ -71,21 +103,52 @@ public class VetCertificateController extends BaseController
{
startPage();
Long currentUserId = getCurrentUserId();
if (currentUserId == null) {
return getDataTable(List.of());
}
// 如果是管理员可以查看所有证书不过滤用户ID
// 管理员可以查看所有普通用户只能查看自己的
if (!isAdmin()) {
// 普通用户只查询自己的证书
vetCertificate.setUserId(currentUserId);
}
// 管理员不设置userId询所有证书
// 管理员不设置userId看所有
List<VetCertificate> list = vetCertificateService.selectVetCertificateList(vetCertificate);
return getDataTable(list);
}
/**
* 用户详情页查询证书专用于用户详情页
* 查看指定用户的证书列表
*/
@PreAuthorize("@ss.hasPermi('vet:certificate:list')")
@GetMapping("/listForDetail")
public TableDataInfo listForDetail(@RequestParam(value = "userId", required = false) Long userId)
{
startPage();
Long currentUserId = getCurrentUserId();
if (currentUserId == null) {
return getDataTable(List.of());
}
// 如果没有传userId返回空
if (userId == null) {
return getDataTable(List.of());
}
// 权限检查管理员或查看自己
if (!isAdmin() && !userId.equals(currentUserId)) {
return getDataTable(List.of());
}
VetCertificate query = new VetCertificate();
query.setUserId(userId);
List<VetCertificate> list = vetCertificateService.selectVetCertificateList(query);
return getDataTable(list);
}
/**
* 导出兽医执业证书列表
*/
@ -106,6 +169,10 @@ public class VetCertificateController extends BaseController
@GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable("id") Long id)
{
// 权限检查
if (!canAccessCertificate(id)) {
return error("没有权限查看此证书");
}
return success(vetCertificateService.selectVetCertificateById(id));
}
@ -123,6 +190,13 @@ public class VetCertificateController extends BaseController
return error("用户未登录");
}
// 权限检查普通用户只能给自己添加证书
if (!isAdmin() && vetCertificate.getUserId() != null &&
!vetCertificate.getUserId().equals(currentUserId)) {
return error("没有权限为其他用户添加证书");
}
// 设置用户ID和创建人
vetCertificate.setUserId(currentUserId);
vetCertificate.setCreateBy(SecurityUtils.getUsername());
@ -137,6 +211,20 @@ public class VetCertificateController extends BaseController
@PutMapping
public AjaxResult edit(@RequestBody VetCertificate vetCertificate)
{
// 权限检查
if (!canAccessCertificate(vetCertificate.getId())) {
return error("没有权限修改此证书");
}
// 如果是普通用户确保不能修改用户ID
if (!isAdmin()) {
Long currentUserId = getCurrentUserId();
if (currentUserId != null && vetCertificate.getUserId() != null &&
!vetCertificate.getUserId().equals(currentUserId)) {
return error("不能修改证书所属用户");
}
}
return toAjax(vetCertificateService.updateVetCertificate(vetCertificate));
}
@ -145,19 +233,34 @@ public class VetCertificateController extends BaseController
*/
@PreAuthorize("@ss.hasPermi('vet:certificate:remove')")
@Log(title = "兽医执业证书", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
// 权限检查检查是否所有证书都可以删除
if (!isAdmin()) {
for (Long id : ids) {
if (!canAccessCertificate(id)) {
return error("没有权限删除证书ID: " + id);
}
}
}
return toAjax(vetCertificateService.deleteVetCertificateByIds(ids));
}
/**
* 根据用户ID获取证书列表
* 根据用户ID获取证书列表兼容旧版本建议前端迁移到listForDetail
*/
@GetMapping("/user/{userId}")
public AjaxResult getByUserId(@PathVariable Long userId)
{
Long currentUserId = getCurrentUserId();
// 权限检查
if (!isAdmin() && !userId.equals(currentUserId)) {
return error("没有权限查看其他用户的证书");
}
List<VetCertificate> list = vetCertificateService.selectCertificatesByUserId(userId);
return success(list);
}
@ -168,6 +271,13 @@ public class VetCertificateController extends BaseController
@GetMapping("/expiring/{userId}")
public AjaxResult getExpiringCertificates(@PathVariable Long userId)
{
Long currentUserId = getCurrentUserId();
// 权限检查
if (!isAdmin() && !userId.equals(currentUserId)) {
return error("没有权限查看其他用户的证书");
}
List<VetCertificate> list = vetCertificateService.selectExpiringCertificates(userId);
return success(list);
}
@ -178,6 +288,13 @@ public class VetCertificateController extends BaseController
@GetMapping("/statistics/{userId}")
public AjaxResult getStatistics(@PathVariable Long userId)
{
Long currentUserId = getCurrentUserId();
// 权限检查
if (!isAdmin() && !userId.equals(currentUserId)) {
return error("没有权限查看其他用户的统计信息");
}
Map<String, Object> statistics = vetCertificateService.getCertificateStatistics(userId);
return success(statistics);
}
@ -188,7 +305,14 @@ public class VetCertificateController extends BaseController
@PostMapping("/manual-check/{userId}")
public AjaxResult manualCheck(@PathVariable Long userId)
{
Long currentUserId = getCurrentUserId();
// 权限检查
if (!isAdmin() && !userId.equals(currentUserId)) {
return error("没有权限为其他用户检查证书");
}
vetCertificateService.manualCheckCertificates(userId);
return success("检查完成");
return success("证书检查完成");
}
}

87
chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetExperienceArticleController.java

@ -7,7 +7,9 @@ import com.chenhai.common.core.page.TableDataInfo;
import com.chenhai.common.enums.BusinessType;
import com.chenhai.common.utils.SecurityUtils;
import com.chenhai.common.utils.poi.ExcelUtil;
import com.chenhai.vet.domain.VetArticleCategory;
import com.chenhai.vet.domain.VetExperienceArticle;
import com.chenhai.vet.mapper.VetArticleCategoryMapper;
import com.chenhai.vet.service.IVetExperienceArticleService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
@ -26,6 +28,9 @@ public class VetExperienceArticleController extends BaseController {
@Autowired
private IVetExperienceArticleService vetExperienceArticleService;
@Autowired // 确保有这个注解
private VetArticleCategoryMapper vetArticleCategoryMapper;
/**
* 查询兽医经验文章列表
@ -65,6 +70,53 @@ public class VetExperienceArticleController extends BaseController {
return toAjax(vetExperienceArticleService.deleteVetExperienceArticleByIds(ids));
}
@GetMapping("/options")
public AjaxResult getCategoryOptions() {
VetArticleCategory query = new VetArticleCategory();
query.setStatus("0");
List<VetArticleCategory> categories = vetArticleCategoryMapper.selectVetArticleCategoryList(query);
// 排序如果sortOrder为null放到最后
if (categories != null) {
categories.sort((c1, c2) -> {
Integer order1 = c1.getSortOrder() != null ? c1.getSortOrder() : 999;
Integer order2 = c2.getSortOrder() != null ? c2.getSortOrder() : 999;
return order1.compareTo(order2);
});
}
List<Map<String, Object>> options = new ArrayList<>();
for (VetArticleCategory category : categories) {
Map<String, Object> option = new LinkedHashMap<>();
option.put("value", category.getId());
option.put("label", category.getName());
options.add(option);
}
return AjaxResult.success(options);
}
/**
* 根据分类查询文章
*/
@GetMapping("/{categoryId}/articles")
public TableDataInfo getArticlesByCategory(
@PathVariable Long categoryId,
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
startPage();
// 创建查询对象
VetExperienceArticle query = new VetExperienceArticle();
query.setStatus("1"); // 只查询已发布的文章
query.setCategoryId(categoryId); // 设置分类ID
List<VetExperienceArticle> list = vetExperienceArticleService.selectVetExperienceArticleList(query);
return getDataTable(list);
}
/**
@ -111,13 +163,13 @@ public class VetExperienceArticleController extends BaseController {
// 3. 判断是否是当前用户发布的文章
Long currentUserId = getUserId();
boolean isOwner = currentUserId != null && currentUserId.equals(article.getVetId());
boolean isOwner = currentUserId != null && currentUserId.equals(article.getUserId());
// 4. 获取相关文章
List<VetExperienceArticle> relatedArticles = vetExperienceArticleService.selectRelatedArticles(id, article.getCategoryId(), 4);
// 5. 获取作者的其他文章
List<VetExperienceArticle> authorArticles = vetExperienceArticleService.selectArticlesByVetId(article.getVetId(), 5);
List<VetExperienceArticle> authorArticles = vetExperienceArticleService.selectArticlesByVetId(article.getUserId(), 5);
Map<String, Object> result = new HashMap<>();
result.put("article", article);
@ -131,7 +183,7 @@ public class VetExperienceArticleController extends BaseController {
/**
* 发布新文章论坛发布接口
*/
@PreAuthorize("@ss.hasPermi('vet:article:add')")
/*@PreAuthorize("@ss.hasPermi('vet:article:add')")*/
@Log(title = "发布经验文章", businessType = BusinessType.INSERT)
@PostMapping("/forum/publish")
public AjaxResult publishForumArticle(@RequestBody VetExperienceArticle article) {
@ -139,7 +191,7 @@ public class VetExperienceArticleController extends BaseController {
String currentUsername = SecurityUtils.getUsername();
// 设置作者信息
article.setVetId(currentUserId);
article.setUserId(currentUserId);
article.setVetName(currentUsername); // 暂时用用户名可以根据需要从用户表获取真实姓名
// 设置文章状态为已发布
@ -174,7 +226,7 @@ public class VetExperienceArticleController extends BaseController {
/**
* 获取当前用户的文章
*/
@PreAuthorize("@ss.hasPermi('vet:article:my')")
/* @PreAuthorize("@ss.hasPermi('vet:article:my')")*/
@GetMapping("/forum/myArticles")
public TableDataInfo getMyForumArticles(
@RequestParam(value = "status", required = false) String status) {
@ -182,7 +234,7 @@ public class VetExperienceArticleController extends BaseController {
Long currentUserId = getUserId();
VetExperienceArticle query = new VetExperienceArticle();
query.setVetId(currentUserId);
query.setUserId(currentUserId);
if (status != null && !status.isEmpty()) {
query.setStatus(status);
@ -247,10 +299,29 @@ public class VetExperienceArticleController extends BaseController {
return success(hotTags);
}
/**
* 2. 根据标签搜索文章带分页
*/
@GetMapping("/listByTag")
public TableDataInfo listByTag(@RequestParam("tag") String tag,
@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize) {
startPage();
// 创建查询对象
VetExperienceArticle query = new VetExperienceArticle();
query.setStatus("1"); // 只查已发布的
query.setTags(tag); // 设置标签条件
List<VetExperienceArticle> list = vetExperienceArticleService.selectVetExperienceArticleList(query);
return getDataTable(list);
}
/**
* 新增文章 - 调整为论坛发布模式
*/
@PreAuthorize("@ss.hasPermi('vet:article:add')")
/* @PreAuthorize("@ss.hasPermi('vet:article:add')")*/
@Log(title = "兽医经验文章", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody VetExperienceArticle vetExperienceArticle) {
@ -282,7 +353,7 @@ public class VetExperienceArticleController extends BaseController {
/**
* 导出兽医经验文章列表
*/
@PreAuthorize("@ss.hasPermi('vet:article:export')")
/* @PreAuthorize("@ss.hasPermi('vet:article:export')")*/
@Log(title = "兽医经验文章", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, VetExperienceArticle vetExperienceArticle) {

19
chenhai-admin/src/main/java/com/chenhai/web/controller/vet/VetTrainingVideoController.java

@ -31,29 +31,26 @@ public class VetTrainingVideoController extends BaseController {
*/
@PostMapping("/upload")
public AjaxResult uploadVideo(
@RequestParam String title,
@RequestParam MultipartFile videoFile,
@RequestParam(required = false) String description,
@RequestParam(required = false) String category,
@RequestParam(required = false) String tags,
@RequestParam(required = false, defaultValue = "1") String status,
@RequestParam(required = false) MultipartFile coverImage) {
@RequestParam("title") String title, // 改为 @RequestParam
@RequestParam("videoFile") MultipartFile videoFile, // 文件字段保持 @RequestPart 或改为 @RequestParam
@RequestParam(value = "description", required = false) String description,
@RequestParam(value = "category", required = false) String category,
@RequestParam(value = "status", required = false, defaultValue = "1") String status) {
Long userId = getCurrentUserId();
try {
VetTrainingVideo video = new VetTrainingVideo();
video.setUserId(userId); // 修正变量名
video.setUserId(userId);
video.setTitle(title);
video.setDescription(description);
video.setCategory(category);
video.setTags(tags);
video.setStatus(status);
// 上传并保存视频
String result = trainingVideoService.uploadAndSave(video, videoFile, coverImage);
String result = trainingVideoService.uploadAndSave(video, videoFile, null);
return success(result);
} catch (Exception e) {
e.printStackTrace();
return error("上传失败:" + e.getMessage());
}
}

4
chenhai-admin/src/main/resources/application.yml

@ -115,8 +115,8 @@ token:
# 微信小程序配置
wx:
muhu:
app-id: wxb5becc8d6d8123a6
app-secret: 74f4211d3985aa782ff9148aa00f824e
app-id: wx9bf7f4e81a1b2d6b
app-secret: b743202d5c1029ab214d94026524c4b7
vet:
app-id: ${WX_MINI_APPID:your_app_id}
app-secret: ${WX_MINI_SECRET:your_app_secret}

14
chenhai-system/src/main/java/com/chenhai/vet/domain/VetExperienceArticle.java

@ -42,7 +42,7 @@ public class VetExperienceArticle extends BaseEntity
/** 作者(兽医)ID */
@Excel(name = "作者", readConverterExp = "兽=医")
private Long vetId;
private Long userId;
/** 兽医姓名 */
@Excel(name = "兽医姓名")
@ -61,7 +61,7 @@ public class VetExperienceArticle extends BaseEntity
private Long categoryId;
/** 分类名称 */
@Excel(name = "分类名称")
@Excel(name = "分类名称",dictType = "vet_experience_category")
private String categoryName;
/** 标签(逗号分隔) */
@ -190,14 +190,14 @@ public class VetExperienceArticle extends BaseEntity
return images;
}
public void setVetId(Long vetId)
public void setUserId(Long userId)
{
this.vetId = vetId;
this.userId = userId;
}
public Long getVetId()
public Long getUserId()
{
return vetId;
return userId;
}
public void setVetName(String vetName)
@ -329,7 +329,7 @@ public class VetExperienceArticle extends BaseEntity
.append("summary", getSummary())
.append("coverImage", getCoverImage())
.append("images", getImages())
.append("vetId", getVetId())
.append("userId", getUserId())
.append("vetName", getVetName())
.append("vetAvatar", getVetAvatar())
.append("vetTitle", getVetTitle())

36
chenhai-system/src/main/java/com/chenhai/vet/mapper/VetArticleCategoryMapper.java

@ -4,44 +4,10 @@ import com.chenhai.vet.domain.VetArticleCategory;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 文章分类Mapper接口
*/
@Mapper
public interface VetArticleCategoryMapper {
/**
* 查询文章分类
*/
VetArticleCategory selectVetArticleCategoryById(Long id);
/**
* 查询文章分类列表
* 这个方法名必须和XML中的id完全一致
*/
List<VetArticleCategory> selectVetArticleCategoryList(VetArticleCategory vetArticleCategory);
/**
* 查询启用的分类列表
*/
List<VetArticleCategory> selectEnabledCategories();
/**
* 新增文章分类
*/
int insertVetArticleCategory(VetArticleCategory vetArticleCategory);
/**
* 修改文章分类
*/
int updateVetArticleCategory(VetArticleCategory vetArticleCategory);
/**
* 删除文章分类
*/
int deleteVetArticleCategoryById(Long id);
/**
* 批量删除文章分类
*/
int deleteVetArticleCategoryByIds(Long[] ids);
}

5
chenhai-system/src/main/java/com/chenhai/vet/mapper/VetExperienceArticleMapper.java

@ -86,4 +86,9 @@ public interface VetExperienceArticleMapper
* 增加收藏数
*/
int incrementCollectCount(@Param("id") Long id);
/**
* 根据标签筛选文章
*/
List<VetExperienceArticle> selectArticlesByTag(@Param("tag") String tag);
}

2
chenhai-system/src/main/java/com/chenhai/vet/service/IVetExperienceArticleService.java

@ -112,4 +112,6 @@ public interface IVetExperienceArticleService
* 获取热门标签
*/
List<Map<String, Object>> selectHotTags(Integer limit);
List<VetExperienceArticle> selectArticlesByTag(String tag);
}

14
chenhai-system/src/main/java/com/chenhai/vet/service/impl/VetExperienceArticleServiceImpl.java

@ -193,7 +193,7 @@ public class VetExperienceArticleServiceImpl implements IVetExperienceArticleSer
// 获取兽医总数去重
Set<Long> vetIds = new HashSet<>();
for (VetExperienceArticle article : allArticles) {
vetIds.add(article.getVetId());
vetIds.add(article.getUserId());
}
statistics.put("totalVets", vetIds.size());
@ -214,6 +214,17 @@ public class VetExperienceArticleServiceImpl implements IVetExperienceArticleSer
return statistics;
}
/**
* 根据标签筛选文章简单实现
*/
@Override
public List<VetExperienceArticle> selectArticlesByTag(String tag) {
if (tag == null || tag.trim().isEmpty()) {
return new ArrayList<>();
}
return vetExperienceArticleMapper.selectArticlesByTag(tag.trim());
}
@Override
public List<Map<String, Object>> selectHotTags(Integer limit) {
// 从所有文章中提取标签并统计热度
@ -248,4 +259,5 @@ public class VetExperienceArticleServiceImpl implements IVetExperienceArticleSer
return hotTags;
}
}

34
chenhai-system/src/main/resources/mapper/vet/VetArticleCategoryMapper.xml

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.chenhai.vet.mapper.VetArticleCategoryMapper">
<resultMap type="com.chenhai.vet.domain.VetArticleCategory" id="VetArticleCategoryResult">
<result property="id" column="id"/>
<result property="name" column="name"/>
<result property="description" column="description"/>
<result property="icon" column="icon"/>
<result property="sortOrder" column="sort_order"/>
<result property="status" column="status"/>
<result property="articleCount" column="article_count"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectVetArticleCategoryVo">
select id, name, description, icon, sort_order, status, create_time, update_time
from vet_article_category
</sql>
<!-- 这个方法名必须和Mapper接口中的方法名完全一致 -->
<select id="selectVetArticleCategoryList" parameterType="com.chenhai.vet.domain.VetArticleCategory" resultMap="VetArticleCategoryResult">
<include refid="selectVetArticleCategoryVo"/>
<where>
<if test="name != null and name != ''"> and name like concat('%', #{name}, '%')</if>
<if test="status != null and status != ''"> and status = #{status}</if>
</where>
order by sort_order asc
</select>
</mapper>

22
chenhai-system/src/main/resources/mapper/vet/VetExperienceArticleMapper.xml

@ -11,7 +11,7 @@
<result property="summary" column="summary"/>
<result property="coverImage" column="cover_image"/>
<result property="images" column="images"/>
<result property="vetId" column="vet_id"/>
<result property="userId" column="user_id"/>
<result property="vetName" column="vet_name"/>
<result property="vetAvatar" column="vet_avatar"/>
<result property="vetTitle" column="vet_title"/>
@ -32,7 +32,7 @@
</resultMap>
<sql id="selectVetExperienceArticleVo">
select id, title, content, summary, cover_image, images, vet_id, vet_name, vet_avatar, vet_title, category_id, category_name, tags, view_count, like_count, collect_count, is_top, is_featured, status, is_sensitive, sensitive_words, publish_time, create_time, update_time
select id, title, content, summary, cover_image, images, user_id, vet_name, vet_avatar, vet_title, category_id, category_name, tags, view_count, like_count, collect_count, is_top, is_featured, status, is_sensitive, sensitive_words, publish_time, create_time, update_time
from vet_experience_article
</sql>
@ -46,7 +46,7 @@
<include refid="selectVetExperienceArticleVo"/>
<where>
<if test="title != null and title != ''"> and title like concat('%', #{title}, '%')</if>
<if test="vetId != null"> and vet_id = #{vetId}</if>
<if test="userId != null"> and user_id = #{userId}</if>
<if test="vetName != null and vetName != ''"> and vet_name like concat('%', #{vetName}, '%')</if>
<if test="categoryId != null"> and category_id = #{categoryId}</if>
<if test="categoryName != null and categoryName != ''"> and category_name like concat('%', #{categoryName}, '%')</if>
@ -73,7 +73,7 @@
or tags like concat('%', #{keyword}, '%')
)
</if>
<if test="vetId != null"> and vet_id = #{vetId}</if>
<if test="userId != null"> and user_id = #{userId}</if>
<if test="categoryId != null"> and category_id = #{categoryId}</if>
<if test="isFeatured != null"> and is_featured = #{isFeatured}</if>
<if test="isTop != null"> and is_top = #{isTop}</if>
@ -150,7 +150,7 @@
<if test="summary != null">summary,</if>
<if test="coverImage != null">cover_image,</if>
<if test="images != null">images,</if>
<if test="vetId != null">vet_id,</if>
<if test="userId != null">user_id,</if>
<if test="vetName != null and vetName != ''">vet_name,</if>
<if test="vetAvatar != null">vet_avatar,</if>
<if test="vetTitle != null">vet_title,</if>
@ -175,7 +175,7 @@
<if test="summary != null">#{summary},</if>
<if test="coverImage != null">#{coverImage},</if>
<if test="images != null">#{images},</if>
<if test="vetId != null">#{vetId},</if>
<if test="userId != null">#{userId},</if>
<if test="vetName != null and vetName != ''">#{vetName},</if>
<if test="vetAvatar != null">#{vetAvatar},</if>
<if test="vetTitle != null">#{vetTitle},</if>
@ -204,7 +204,7 @@
<if test="summary != null">summary = #{summary},</if>
<if test="coverImage != null">cover_image = #{coverImage},</if>
<if test="images != null">images = #{images},</if>
<if test="vetId != null">vet_id = #{vetId},</if>
<if test="userId != null">user_id = #{userId},</if>
<if test="vetName != null and vetName != ''">vet_name = #{vetName},</if>
<if test="vetAvatar != null">vet_avatar = #{vetAvatar},</if>
<if test="vetTitle != null">vet_title = #{vetTitle},</if>
@ -235,4 +235,12 @@
#{id}
</foreach>
</delete>
<select id="selectArticlesByTag" parameterType="String" resultMap="VetExperienceArticleResult">
<include refid="selectVetExperienceArticleVo"/>
WHERE status = '1' <!-- 只查询已发布的文章 -->
AND tags LIKE CONCAT('%', #{tag}, '%')
ORDER BY publish_time DESC
LIMIT 20 <!-- 限制返回20条 -->
</select>
</mapper>

11
chenhai-system/src/main/resources/mapper/vet/VetTrainingVideoMapper.xml

@ -28,7 +28,7 @@
category, tags, duration, file_size, view_count,
status, create_time, update_time
) VALUES (
#{vetId}, #{title}, #{description}, #{videoUrl}, #{coverImage},
#{userId}, #{title}, #{description}, #{videoUrl}, #{coverImage},
#{category}, #{tags}, #{duration}, #{fileSize}, #{viewCount},
#{status}, #{createTime}, #{updateTime}
)
@ -63,7 +63,7 @@
AND v.category = #{category}
</if>
<if test="userName != null and userName != ''">
AND u.nick_name LIKE CONCAT('%', #{vetName}, '%')
AND u.nick_name LIKE CONCAT('%', #{userName}, '%')
</if>
ORDER BY v.create_time DESC
</select>
@ -102,9 +102,8 @@
ORDER BY v.create_time DESC
</select>
<update id="deleteVideoById">
UPDATE vet_training_video
SET del_flag = '1'
<delete id="deleteVideoById">
DELETE FROM vet_training_video
WHERE id = #{id}
</update>
</delete>
</mapper>

17
chenhai-ui/src/api/vet/training.js

@ -5,26 +5,17 @@ import request from '@/utils/request'
export default {
// 上传视频
uploadVideo(data) {
const formData = new FormData()
Object.keys(data).forEach(key => {
if (data[key] !== undefined && data[key] !== null) {
if (key === 'videoFile' || key === 'coverImage') {
formData.append(key, data[key])
} else {
formData.append(key, data[key])
}
}
})
// 注意:这里直接返回请求,不要重新创建 FormData
return request({
url: '/vet/training/upload',
method: 'post',
data: formData,
data: data, // data 已经是 FormData 对象
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 300000 // 5分钟超时,视频上传需要时间
timeout: 300000
})
},
// 获取公开视频列表

578
chenhai-ui/src/views/vet/training/TrainingHome.vue

@ -110,22 +110,41 @@
@click="showVideoDetail(video)"
>
<div class="cover">
<img v-if="video.coverImage" :src="video.coverImage" :alt="video.title" />
<div v-else class="no-cover">
<i class="el-icon-video-camera"></i>
<!-- 封面图片 -->
<img
v-if="video.coverImage && video.coverImage !== 'null'"
:src="getCoverImage(video)"
:alt="video.title"
@load="handleCoverImageLoad(video.id)"
@error="handleCoverImageError(video)"
style="width: 100%; height: 100%; object-fit: cover;"
/>
<!-- 默认封面 -->
<div
v-else
class="default-cover"
:style="{ background: getCategoryColor(video.category) }"
>
<div class="cover-content">
<i class="el-icon-video-camera cover-icon"></i>
<span class="cover-text">{{ getCategoryText(video.category) }}</span>
</div>
</div>
<div class="duration">{{ formatDuration(video.duration) }}</div>
</div>
<div class="info">
<h4 class="title">{{ video.title }}</h4>
<div class="title">{{ video.title }}</div>
<div class="meta">
<span class="author">
<span>
<i class="el-icon-user"></i>
{{ video.nickName || video.userName || '未知' }}
{{ video.userName || '未知' }}
</span>
<span class="views">
<span>
<i class="el-icon-view"></i>
{{ video.viewCount || 0 }}次观看
{{ video.viewCount || 0 }}
</span>
</div>
<div class="time">{{ formatTime(video.createTime) }}</div>
@ -251,9 +270,14 @@
<div class="my-video-item" v-for="video in myVideos" :key="video.id">
<div class="video-info" @click="showVideoDetail(video)">
<div class="left">
<img v-if="video.coverImage" :src="video.coverImage" class="cover-small" />
<div v-else class="cover-small-placeholder">
<i class="el-icon-video-camera"></i>
<div class="cover-small-container">
<img v-if="video.coverImage && video.coverImage !== 'null'"
:src="getCoverImage(video)"
class="cover-small"
@error="handleCoverImageError(video)" />
<div v-else class="cover-small-placeholder" :style="{ background: getCategoryColor(video.category) }">
<i class="el-icon-video-camera"></i>
</div>
</div>
<div class="details">
<div class="title">{{ video.title }}</div>
@ -262,7 +286,7 @@
{{ video.status === '1' ? '公开' : '私有' }}
</el-tag>
<span class="time">{{ formatTime(video.createTime) }}</span>
<span class="views">{{ video.viewCount }}次观看</span>
<span class="views">{{ video.viewCount || 0 }}次观看</span>
</div>
</div>
</div>
@ -287,29 +311,66 @@
:visible.sync="showDetailDialog"
:title="currentVideo ? currentVideo.title : '视频详情'"
width="800px"
@open="handleDialogOpen"
@close="handleDialogClose"
>
<div v-if="currentVideo" class="video-detail-dialog">
<!-- 视频播放器 -->
<div class="video-player">
<video
v-if="currentVideo.videoUrl"
:src="currentVideo.videoUrl"
controls
autoplay
style="width: 100%; max-height: 400px;"
></video>
<div v-else class="no-video">
<div v-if="!videoPlayerReady && !videoLoadFailed" class="video-loading">
<i class="el-icon-loading"></i>
<p>加载视频中...</p>
</div>
<div v-else-if="videoLoadFailed" class="video-error">
<i class="el-icon-video-camera"></i>
<p>视频加载失败</p>
<p class="error-tip">可能的原因</p>
<ul>
<li>视频文件不存在</li>
<li>网络连接问题</li>
<li>视频格式不支持</li>
</ul>
<div class="error-actions">
<el-button type="primary" @click="retryLoadVideo" size="small">
<i class="el-icon-refresh"></i> 重试加载
</el-button>
<el-button @click="openVideoInNewTab" size="small">
<i class="el-icon-link"></i> 在新标签页打开
</el-button>
<el-button @click="showDetailDialog = false" size="small">
关闭
</el-button>
</div>
</div>
<video
v-else
ref="videoPlayer"
:src="currentVideo.videoUrl"
controls
preload="metadata"
style="width: 100%; max-height: 400px; border-radius: 6px;"
@error="handleVideoError"
@loadeddata="handleVideoLoaded"
@canplay="handleVideoCanPlay"
crossorigin="anonymous"
>
<source :src="currentVideo.videoUrl" type="video/mp4">
<source :src="currentVideo.videoUrl" type="video/webm">
您的浏览器不支持视频播放
</video>
</div>
<!-- 视频信息 -->
<div class="video-info-card">
<div class="info-row">
<span class="label">发布者</span>
<span class="value">{{ currentVideo.nickName || currentVideo.userName || '未知' }}</span>
<span class="value">{{ currentVideo.userName || '未知' }}</span>
</div>
<div class="info-row">
<span class="label">分类</span>
<span class="value">{{ getCategoryText(currentVideo.category) }}</span>
</div>
<div class="info-row">
<span class="label">发布时间</span>
@ -321,7 +382,15 @@
</div>
<div v-if="currentVideo.description" class="info-row">
<span class="label">视频描述</span>
<p class="value">{{ currentVideo.description }}</p>
<p class="value description-text">{{ currentVideo.description }}</p>
</div>
<div class="info-row">
<span class="label">视频地址</span>
<p class="value">
<el-link :href="currentVideo.videoUrl" target="_blank" type="primary">
{{ currentVideo.videoUrl }}
</el-link>
</p>
</div>
</div>
</div>
@ -363,7 +432,14 @@ export default {
// ========== ==========
showDetailDialog: false,
currentVideo: null
currentVideo: null,
videoPlayerReady: false,
videoLoadFailed: false,
// ========== ==========
coverLoadingStates: new Map(),
coverErrorStates: new Map(),
defaultCoverCache: new Map(),
}
},
@ -393,14 +469,28 @@ export default {
async loadPublicVideos() {
try {
this.loading = true
console.log('🔄 加载公开视频...')
const params = {
title: this.searchKeyword,
category: this.filterCategory
category: this.filterCategory,
pageNum: 1,
pageSize: 100
}
const res = await trainingApi.getPublicVideos(params)
this.videos = res.data ? res.data.rows : []
console.log('✅ 公开视频响应:', res)
if (res && res.rows) {
console.log(`📊 找到 ${res.rows.length} 个视频`)
this.videos = res.rows
} else {
console.warn('⚠️ 公开视频响应数据格式不正确')
this.videos = []
}
} catch (error) {
console.error('加载视频失败:', error)
console.error('加载公开视频失败:', error)
this.$message.error('加载失败')
} finally {
this.loading = false
@ -411,13 +501,27 @@ export default {
async loadMyVideos() {
try {
this.myVideosLoading = true
console.log('🔄 加载我的视频...')
const params = {
title: this.mySearchKeyword
title: this.mySearchKeyword,
pageNum: 1,
pageSize: 100
}
const res = await trainingApi.getMyVideos(params)
this.myVideos = res.data ? res.data.rows : []
console.log('✅ 我的视频响应:', res)
if (res && res.rows) {
console.log(`📊 找到 ${res.rows.length} 个我的视频`)
this.myVideos = res.rows
} else {
console.warn('⚠️ 我的视频响应数据格式不正确')
this.myVideos = []
}
} catch (error) {
console.error('加载我的视频失败:', error)
console.error('加载我的视频失败:', error)
this.$message.error('加载失败')
} finally {
this.myVideosLoading = false
@ -511,10 +615,141 @@ export default {
}
},
// ========== ==========
//
showVideoDetail(video) {
this.currentVideo = video
this.showDetailDialog = true
console.log('🔍 查看视频详情,原始数据:', video)
if (!video || !video.videoUrl) {
this.$message.error('视频信息不完整')
return
}
// URL
const fullVideoUrl = this.getVideoUrl(video.videoUrl)
console.log('📹 视频URL分析:')
console.log(' 原始URL:', video.videoUrl)
console.log(' 完整URL:', fullVideoUrl)
console.log(' 是否有效URL:', fullVideoUrl.startsWith('http') || fullVideoUrl.startsWith('/'))
// URL访
this.testVideoUrl(fullVideoUrl).then(isAccessible => {
if (!isAccessible) {
console.log('⚠️ 视频URL可能无法访问')
}
this.currentVideo = {
...video,
videoUrl: fullVideoUrl,
coverImage: this.getCoverImage(video)
}
this.showDetailDialog = true
}).catch(() => {
this.currentVideo = {
...video,
videoUrl: fullVideoUrl,
coverImage: this.getCoverImage(video)
}
this.showDetailDialog = true
})
},
// URL访
testVideoUrl(url) {
return new Promise((resolve) => {
const testVideo = document.createElement('video')
testVideo.preload = 'metadata'
testVideo.onloadedmetadata = () => {
console.log('✅ 视频URL可访问,时长:', testVideo.duration)
resolve(true)
}
testVideo.onerror = () => {
console.log('❌ 视频URL不可访问:', url)
resolve(false)
}
//
setTimeout(() => {
console.log('⏰ 视频加载超时')
resolve(false)
}, 3000)
testVideo.src = url
})
},
//
handleDialogOpen() {
this.videoPlayerReady = false
this.videoLoadFailed = false
//
this.$nextTick(() => {
if (this.$refs.videoPlayer) {
this.$refs.videoPlayer.load()
}
})
},
//
handleDialogClose() {
//
if (this.$refs.videoPlayer) {
this.$refs.videoPlayer.pause()
}
this.currentVideo = null
this.videoPlayerReady = false
this.videoLoadFailed = false
},
//
handleVideoCanPlay() {
console.log('🎬 视频可以播放了')
this.videoPlayerReady = true
this.videoLoadFailed = false
},
//
handleVideoLoaded() {
console.log('✅ 视频加载完成')
},
//
handleVideoError(event) {
console.error('❌ 视频加载错误:', event)
this.videoLoadFailed = true
this.$message.error('视频加载失败,请检查视频文件')
},
//
retryLoadVideo() {
console.log('🔄 重试加载视频')
this.videoPlayerReady = false
this.videoLoadFailed = false
if (this.currentVideo && this.currentVideo.videoUrl) {
//
const videoUrl = this.currentVideo.videoUrl +
(this.currentVideo.videoUrl.includes('?') ? '&' : '?') +
't=' + Date.now()
this.currentVideo.videoUrl = videoUrl
this.$nextTick(() => {
const videoElement = this.$refs.videoPlayer
if (videoElement) {
videoElement.load()
}
})
}
},
//
openVideoInNewTab() {
if (this.currentVideo && this.currentVideo.videoUrl) {
window.open(this.currentVideo.videoUrl, '_blank')
}
},
//
@ -528,19 +763,144 @@ export default {
await trainingApi.deleteVideo(videoId)
this.$message.success('删除成功')
//
this.loadMyVideos()
//
if (this.activeTab === 'list') {
this.loadPublicVideos()
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
this.$message.error('删除失败')
this.$message.error('删除失败: ' + (error.message || '未知错误'))
}
}
},
//
handleDialogClose() {
this.currentVideo = null
// ========== ==========
//
getCoverImage(video) {
// URL
if (video.coverImage &&
video.coverImage !== 'null' &&
video.coverImage !== 'undefined' &&
video.coverImage !== '') {
return this.getFullUrl(video.coverImage)
}
//
return this.getCategoryDefaultCover(video.category || 'other')
},
//
getCategoryDefaultCover(category) {
//
if (this.defaultCoverCache.has(category)) {
return this.defaultCoverCache.get(category)
}
// SVG
const color = this.getCategoryColor(category)
const text = this.getCategoryText(category)
// SVG
const svgString = `<svg width="320" height="180" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${color}" />
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="16"
fill="white" text-anchor="middle" dy=".3em">
${text}
</text>
</svg>`
// base64
const base64 = btoa(unescape(encodeURIComponent(svgString)))
const dataUrl = `data:image/svg+xml;base64,${base64}`
//
this.defaultCoverCache.set(category, dataUrl)
return dataUrl
},
//
getCategoryColor(category) {
const colors = {
surgery: '#667eea', //
diagnosis: '#67cf77', // 绿
medication: '#ff6b6b', //
other: '#ffb366' //
}
return colors[category] || colors.other
},
//
getCategoryText(category) {
const texts = {
surgery: '手术技巧',
diagnosis: '疾病诊断',
medication: '药物使用',
other: '其他'
}
return texts[category] || '其他'
},
//
handleCoverImageLoad(videoId) {
console.log(`✅ 封面图片加载成功: ${videoId}`)
this.coverLoadingStates.set(videoId, true)
this.coverErrorStates.set(videoId, false)
},
//
handleCoverImageError(video) {
const videoId = video.id
console.log(`❌ 封面图片加载失败: ${videoId}`)
//
this.coverErrorStates.set(videoId, true)
// 使
this.$nextTick(() => {
const imgElements = document.querySelectorAll(`[data-video-id="${videoId}"] img`)
imgElements.forEach(img => {
img.src = this.getCategoryDefaultCover(video.category || 'other')
})
})
},
// ========== URL ==========
// URL
getFullUrl(url) {
if (!url) return ''
//
url = url.trim()
// URLbase64
if (url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('data:') ||
url.startsWith('blob:')) {
return url
}
// /
if (url.startsWith('/')) {
return window.location.origin + url
}
// uploads
return window.location.origin + '/uploads/' + url
},
// URL
getVideoUrl(videoUrl) {
return this.getFullUrl(videoUrl)
},
// ========== ==========
@ -548,13 +908,20 @@ export default {
if (!seconds) return '00:00'
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min}:${sec.toString().padStart(2, '0')}`
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
},
formatTime(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
return dateStr
}
},
formatFileSize(bytes) {
@ -563,7 +930,7 @@ export default {
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
},
}
}
</script>
@ -594,8 +961,6 @@ export default {
gap: 8px;
}
.training-main {
flex: 1;
display: flex;
@ -680,19 +1045,33 @@ export default {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: opacity 0.3s;
}
.no-cover {
/* 默认封面样式 */
.default-cover {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #d9d9d9;
}
.no-cover .el-icon {
font-size: 40px;
.cover-content {
text-align: center;
color: white;
}
.cover-icon {
font-size: 36px;
margin-bottom: 8px;
display: block;
}
.cover-text {
font-size: 14px;
font-weight: 500;
}
.duration {
@ -704,6 +1083,7 @@ export default {
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
z-index: 2;
}
.video-card .info {
@ -837,22 +1217,28 @@ export default {
flex: 1;
}
.cover-small {
.cover-small-container {
position: relative;
width: 80px;
height: 45px;
flex-shrink: 0;
}
.cover-small {
width: 100%;
height: 100%;
border-radius: 4px;
object-fit: cover;
}
.cover-small-placeholder {
width: 80px;
height: 45px;
background: #f5f5f5;
width: 100%;
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #d9d9d9;
color: white;
}
.details {
@ -863,6 +1249,10 @@ export default {
margin: 0 0 6px 0;
font-weight: 500;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.details .meta {
@ -878,25 +1268,79 @@ export default {
padding: 10px 0;
}
.video-player {
margin-bottom: 20px;
background: #000;
/* 视频加载状态 */
.video-loading {
padding: 60px;
text-align: center;
color: #666;
background: #f5f5f5;
border-radius: 6px;
overflow: hidden;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.no-video {
padding: 60px;
.video-loading .el-icon-loading {
font-size: 36px;
margin-bottom: 16px;
color: #409eff;
}
/* 视频错误状态 */
.video-error {
padding: 40px;
text-align: center;
color: white;
background: #666;
color: #f56c6c;
background: #fff5f5;
border-radius: 6px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.no-video .el-icon {
.video-error .el-icon-video-camera {
font-size: 48px;
margin-bottom: 16px;
}
.error-tip {
margin: 16px 0 8px 0;
color: #333;
font-weight: 500;
}
.error-tip ul {
text-align: left;
display: inline-block;
margin: 8px auto;
color: #666;
}
.error-tip li {
margin: 4px 0;
}
.error-actions {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 12px;
}
.video-player {
margin-bottom: 20px;
border-radius: 6px;
overflow: hidden;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.video-info-card {
padding: 16px;
background: #fafafa;
@ -904,18 +1348,28 @@ export default {
}
.info-row {
margin-bottom: 10px;
margin-bottom: 12px;
font-size: 14px;
display: flex;
}
.info-row .label {
color: #666;
font-weight: 500;
margin-right: 8px;
flex-shrink: 0;
width: 80px;
}
.info-row .value {
color: #333;
flex: 1;
}
.description-text {
white-space: pre-wrap;
line-height: 1.5;
margin: 0;
}
/* 加载和空状态 */

Loading…
Cancel
Save