This commit is contained in:
oneao committed 2025-12-26 17:30:43 +08:00
1 parent 4f5218c892
commit 2f8a815c62
32 files changed
+1915 -1738

No files matched your search

@@ -71,6 +71,7 @@ public class ForumPostCommentServiceImpl extends ServiceImpl<ForumPostCommentMap
return Result.success(forumPostCommentVO,"评论成功");
}
public List<ForumPostCommentVO> getPostComments(String forumPostId) {
// 1. 查询所有评论
List<ForumPostCommentVO> list = forumPostCommentMapper.getPostComments(forumPostId);
@@ -35,5 +35,4 @@ public class CompanyDetailVO {
private String position;
private String name;
}
}
@@ -0,0 +1,40 @@
package cn.link56.post.controller;
import cn.link56.common.Result;
import cn.link56.post.model.dto.CreatePostCommentDTO;
import cn.link56.post.model.vo.CreatePostCommentVO;
import cn.link56.post.model.vo.PostCommentVO;
import cn.link56.post.service.PostCommentService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
@RestController
@RequestMapping("/post/comment")
public class PostCommentController {
@Resource
private PostCommentService postCommentService;
@PostMapping("/create")
public Result<CreatePostCommentVO> createPostComment(@RequestBody CreatePostCommentDTO dto){
CreatePostCommentVO commentVO = postCommentService.createPostComment(dto);
return Result.success(commentVO);
}
@GetMapping("/{id}")
public Result<List<PostCommentVO>> listPostComments(@PathVariable("id") @NotNull(message = "文章ID不能为空") Long id){
List<PostCommentVO> postComments = postCommentService.listPostComments(id);
return Result.success(postComments);
}
@PostMapping("/delete/{id}")
public Result<Void> delete(@PathVariable("id") @NotNull(message = "评论ID不能为空") Long postCommentId) {
postCommentService.deletePostComment(postCommentId);
return Result.success();
}
}
@@ -3,13 +3,13 @@ package cn.link56.post.controller;
import cn.link56.common.BasePageVO;
import cn.link56.common.Result;
import cn.link56.post.model.dto.QueryPublicPostDTO;
import cn.link56.post.model.vo.PostTagVO;
import cn.link56.post.model.vo.QueryPublicPostVO;
import cn.link56.post.model.vo.*;
import cn.link56.post.service.PostService;
import cn.link56.post.service.PostTagService;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.constraints.NotNull;
import java.util.List;
@RestController
@@ -32,4 +32,19 @@ public class PostController {
BasePageVO<QueryPublicPostVO> res = postService.queryPublicPosts(dto);
return Result.success(res);
}
@GetMapping("/{id}")
public Result<PublicPostVO> getPublicDetail(@PathVariable("id") @NotNull(message = "文章ID不能为空") Long id){
PublicPostVO publicPostVO = postService.getPublicDetail(id);
return Result.success(publicPostVO);
}
@GetMapping("/company/{id}")
public Result<PostCompanyVO> getForumCompany(@PathVariable("id") @NotNull(message = "公司ID不能为空") Long companyId){
PostCompanyVO postCompanyVO = postService.getPostCompanyById(companyId);
return Result.success(postCompanyVO);
}
}
@@ -1,9 +1,14 @@
package cn.link56.post.mapper;
import cn.link56.post.model.po.PostComment;
import cn.link56.post.model.vo.PostCommentVO;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PostCommentMapper extends BaseMapper<PostComment> {
List<PostCommentVO> selectComments(@Param("postId") Long postId);
}
@@ -2,7 +2,9 @@ package cn.link56.post.mapper;
import cn.link56.post.model.po.Post;
import cn.link56.post.model.vo.EditorDashboardVO;
import cn.link56.post.model.vo.PublicPostVO;
import cn.link56.post.model.vo.QueryEditorPostVO;
import cn.link56.post.model.vo.QueryPublicPostVO;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -33,4 +35,16 @@ public interface PostMapper extends BaseMapper<Post> {
List<EditorDashboardVO.Chart> selectDashboardViewStats(@Param("userId") Long userId);
List<EditorDashboardVO.Chart> selectDashboardNumberStats(@Param("userId") Long userId);
List<QueryPublicPostVO> selectPublicPostPage(
@Param("offset") int offset,
@Param("limit") int limit,
@Param("title") String title,
@Param("tag") String tag
);
Long selectPublicPostTotal(
@Param("title") String title,
@Param("tag") String tag
);
}
@@ -0,0 +1,15 @@
package cn.link56.post.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreatePostCommentDTO {
private Long fatherId;
private Long postId;
private String content;
}
@@ -8,6 +8,9 @@ import lombok.NoArgsConstructor;
@AllArgsConstructor
@NoArgsConstructor
public class QueryPublicPostDTO {
private Integer currentPage;
private Integer pageSize;
private String title;
private String tag;
}
@@ -8,6 +8,8 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@@ -26,14 +28,14 @@ public class PostComment {
private String content;
@TableField(
value = "b_create_user_id",
value = "b_create_by",
fill = FieldFill.INSERT
)
private Long createUserId;
private Long createBy;
@TableField(
value = "b_create_time",
fill = FieldFill.INSERT
)
private Long createTime;
private Date createTime;
}
@@ -0,0 +1,18 @@
package cn.link56.post.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreatePostCommentVO {
private Long id;
private Long fatherId;
private String content;
private Long createId;
private Date createTime;
}
@@ -0,0 +1,31 @@
package cn.link56.post.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostCommentVO {
private Long id;
private Long fatherId;
private String content;
private Date createTime;
private User publishUser;
private User replyUser;
private List<PostCommentVO> children;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class User{
private Long id;
private Long companyId;
private String name;
private String avatar;
}
}
@@ -0,0 +1,19 @@
package cn.link56.post.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PostCompanyVO {
private Long id;
private String name;
private String logo;
private String profile;
private List<String> areas;
private List<String> industries;
}
@@ -0,0 +1,24 @@
package cn.link56.post.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PublicPostVO {
private Long id;
private String title;
private String content;
private List<String> tags;
private Date activeTime;
private Long viewCount;
private Long commentCount;
private Long companyId;
}
@@ -1,14 +1,133 @@
package cn.link56.post.service;
import cn.link56.common.utils.snowflake.idgen.IdGenerator;
import cn.link56.post.mapper.PostCommentMapper;
import cn.link56.post.model.dto.CreatePostCommentDTO;
import cn.link56.post.model.po.PostComment;
import cn.link56.post.model.po.PostView;
import cn.link56.post.model.vo.CreatePostCommentVO;
import cn.link56.post.model.vo.PostCommentVO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.*;
@Service
public class PostCommentService extends ServiceImpl<PostCommentMapper, PostComment> {
@Resource
private PostCommentMapper postCommentMapper;
/**
* 根据文章 id 获取浏览总数
*/
public Long getTotalByPostId(Long postId) {
return lambdaQuery()
.eq(PostComment::getPostId, postId)
.count();
}
/**
* 根据文章 id 获取最新一条评论
*/
public PostComment getLatestCommentByPostId(Long postId) {
return null;
}
public List<PostCommentVO> listPostComments(Long id) {
// 1. 查询所有评论
List<PostCommentVO> list = postCommentMapper.selectComments(id);
// 2. 构建扁平化树形结构(所有子评论提升到根评论层级)
List<PostCommentVO> rootComments = new ArrayList<>();
Map<Long, PostCommentVO> commentMap = new HashMap<>();
// 2.1 初始化映射并收集根评论
for (PostCommentVO comment : list) {
commentMap.put(comment.getId(), comment);
if (ObjectUtils.isEmpty(comment.getFatherId())) {
rootComments.add(comment);
comment.setChildren(new ArrayList<>()); // 提前初始化children
}
}
// 2.2 遍历所有评论,将子评论挂到根祖先的children中
for (PostCommentVO comment : list) {
if (ObjectUtils.isEmpty(comment.getFatherId())) {
continue; // 跳过根评论
}
// 查找当前评论的根祖先(追溯到没有fatherId的祖先)
PostCommentVO rootAncestor = null;
Long currentFatherId = comment.getFatherId();
while (true) {
PostCommentVO parent = commentMap.get(currentFatherId);
if (parent == null) break; // 父节点不存在,终止循环
if (ObjectUtils.isEmpty(comment.getFatherId())) {
rootAncestor = parent; // 找到根祖先
break;
} else {
currentFatherId = parent.getFatherId(); // 继续向上查找
}
}
// 挂载到根祖先的children中
if (rootAncestor != null && rootAncestor.getChildren() != null) {
rootAncestor.getChildren().add(comment);
}
}
// 3. 按原始顺序排序(可选)
rootComments.forEach(root -> {
if (root.getChildren() != null) {
// 根据需求排序,例如按创建时间升序
root.getChildren().sort(Comparator.comparing(PostCommentVO::getCreateTime));
}
});
return rootComments;
}
@Transactional
public CreatePostCommentVO createPostComment(CreatePostCommentDTO dto) {
PostComment postComment = new PostComment();
BeanUtils.copyProperties(dto,postComment);
postComment.setId(IdGenerator.nextId());
postCommentMapper.insert(postComment);
CreatePostCommentVO commentVO = new CreatePostCommentVO();
BeanUtils.copyProperties(postComment,commentVO);
return commentVO;
}
@Transactional
public void deletePostComment(@NotBlank(message = "评论ID不能为空") Long postCommentId) {
PostComment postComment = postCommentMapper.selectById(postCommentId);
if (ObjectUtils.isEmpty(postComment)){
return;
}
Long fatherId = postComment.getFatherId();
if(ObjectUtils.isEmpty(fatherId)){
postCommentMapper.deleteById(postCommentId);
}else{
LambdaUpdateWrapper<PostComment> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(PostComment::getId,postCommentId)
.or()
.eq(PostComment::getFatherId,postCommentId);
postCommentMapper.delete(updateWrapper);
}
}
}
@@ -2,24 +2,27 @@ package cn.link56.post.service;
import cn.link56.common.BasePageVO;
import cn.link56.common.context.UserContext;
import cn.link56.common.exception.BusinessException;
import cn.link56.common.utils.snowflake.idgen.IdGenerator;
import cn.link56.company.model.po.CompanyPO;
import cn.link56.company.service.CompanyService;
import cn.link56.post.mapper.PostMapper;
import cn.link56.post.model.dto.*;
import cn.link56.post.model.enums.PostStatusEnum;
import cn.link56.post.model.po.Post;
import cn.link56.post.model.vo.EditorDashboardVO;
import cn.link56.post.model.vo.EditorPostVO;
import cn.link56.post.model.vo.QueryEditorPostVO;
import cn.link56.post.model.vo.QueryPublicPostVO;
import cn.link56.post.model.po.PostComment;
import cn.link56.post.model.vo.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import javax.validation.constraints.NotNull;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@@ -29,6 +32,12 @@ public class PostService extends ServiceImpl<PostMapper, Post> {
@Resource
private PostMapper postMapper;
@Resource
private PostViewService postViewService;
@Resource
private PostCommentService postCommentService;
@Resource
private CompanyService companyService;
@Transactional
public EditorPostVO createPost(CreatePostDTO dto) {
@@ -219,10 +228,63 @@ public class PostService extends ServiceImpl<PostMapper, Post> {
public BasePageVO<QueryPublicPostVO> queryPublicPosts(QueryPublicPostDTO dto) {
String title = dto.getTitle();
String tag = dto.getTag();
int offset = (dto.getCurrentPage() - 1) * dto.getPageSize() + 1;
int limit = dto.getCurrentPage() * dto.getPageSize();
BasePageVO<QueryPublicPostVO> result = new BasePageVO<>();
return result;
List<QueryPublicPostVO> queryEditorPostVOS = postMapper.selectPublicPostPage(offset, limit, title, tag);
Long total = postMapper.selectPublicPostTotal(title, tag);
return new BasePageVO<QueryPublicPostVO>(total, queryEditorPostVOS);
}
public PublicPostVO getPublicDetail(Long id) {
Post post = postMapper.selectById(id);
if (post == null) {
throw new BusinessException("帖子不存在或已被删除");
}
if (PostStatusEnum.DRAFT.getCode() == post.getStatus()) {
throw new BusinessException("该帖子尚未发布");
}
PublicPostVO postVO = new PublicPostVO();
BeanUtils.copyProperties(post,postVO);
postVO.setTags(Arrays.stream(StringUtils.split(post.getTag(),",")).collect(Collectors.toList()));
Long viewTotal = postViewService.getTotalByPostId(id);
Long commentTotal = postCommentService.getTotalByPostId(id);
postVO.setViewCount(viewTotal);
postVO.setCommentCount(commentTotal);
PostComment latestCommentByPostId = postCommentService.getLatestCommentByPostId(id);
if (latestCommentByPostId == null){
postVO.setActiveTime(post.getUpdateTime());
} else {
postVO.setActiveTime(latestCommentByPostId.getCreateTime());
}
return postVO;
}
public PostCompanyVO getPostCompanyById(Long id) {
CompanyPO companyPO = companyService.getById(id);
if (companyPO == null){
throw new BusinessException("该公司不存在或已注销");
}
PostCompanyVO postCompanyVO = new PostCompanyVO();
BeanUtils.copyProperties(companyPO,postCompanyVO);
postCompanyVO.setAreas(Arrays.stream(StringUtils.split(companyPO.getArea(),",")).collect(Collectors.toList()));
postCompanyVO.setIndustries(Arrays.stream(StringUtils.split(companyPO.getIndustry(),",")).collect(Collectors.toList()));
return postCompanyVO;
}
}
@@ -11,4 +11,14 @@ import javax.annotation.Resource;
public class PostViewService extends ServiceImpl<PostViewMapper, PostView> {
@Resource
private PostViewMapper postViewMapper;
/**
* 根据文章 id 获取浏览总数
*/
public Long getTotalByPostId(Long postId) {
return lambdaQuery()
.eq(PostView::getPostId, postId)
.count();
}
}
@@ -0,0 +1,42 @@
<?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="cn.link56.post.mapper.PostCommentMapper">
<resultMap id="CommentListMap" type="cn.link56.post.model.vo.PostCommentVO">
<id property="id" column="b_id"/>
<result property="fatherId" column="b_father_id"/>
<result property="content" column="b_content"/>
<result property="createTime" column="b_create_time"/>
<!-- 映射发布公司 -->
<association property="publishUser" javaType="cn.link56.post.model.vo.PostCommentVO$User">
<result property="id" column="pub_user_id"/>
<result property="companyId" column="pub_company_id"/>
<result property="name" column="pub_user_name"/>
<result property="avatar" column="pub_user_avatar"/>
</association>
<!-- 映射回复公司 -->
<association property="replyUser" javaType="cn.link56.post.model.vo.PostCommentVO$User">
<result property="id" column="rep_user_id"/>
<result property="companyId" column="rep_company_id"/>
<result property="name" column="rep_user_name"/>
<result property="avatar" column="rep_user_avatar"/>
</association>
</resultMap>
<select id="selectComments" resultMap="CommentListMap">
SELECT c.*,
pc.b_id AS pub_user_id,pc.b_company_id AS pub_company_id,pc.b_name AS pub_user_name,
rc.b_id AS rep_user_id,rc.b_company_id AS rep_company_id,rc.b_name AS rep_company_id
FROM b_post_comment c
LEFT JOIN b_company_user pc ON c.b_create_by = pc.b_id
LEFT JOIN b_company_user rc ON (
SELECT b_create_by FROM b_post_comment WHERE b_id = c.b_father_id
) = rc.b_id
WHERE c.b_post_id = #{postId}
ORDER BY c.b_create_time
</select>
</mapper>
@@ -163,7 +163,7 @@
p.b_id AS id,
p.b_title AS title,
p.b_tag AS tagsString,
p.company_id AS companyId,
p.b_company_id AS companyId,
bc.b_name AS companyName,
ISNULL(pv.viewCount, 0) AS viewCount,
@@ -173,10 +173,10 @@
ISNULL(pc.lastCommentTime, p.b_update_time) AS activeTime,
ROW_NUMBER() OVER (
ORDER BY ISNULL(pc.lastCommentTime, p.b_update_time) DESC
ORDER BY ISNULL(pc.lastCommentTime, p.b_update_time) DESC
) AS rn
FROM b_post p
LEFT JOIN b_company bc ON bc.b_id = p.company_id
LEFT JOIN b_company bc ON bc.b_id = p.b_company_id
LEFT JOIN (
SELECT
b_post_id,
@@ -193,10 +193,11 @@
GROUP BY b_post_id
) pc ON pc.b_post_id = p.b_id
<where>
AND p.b_status = 1
<if test="title != null and title != ''">
AND p.b_title LIKE '%' + #{title} + '%'
</if>
<if test="tag != null and tag != -1">
<if test="tag != null and tag != ''">
AND p.b_tag LIKE '%' + #{tag} + '%'
</if>
</where>
@@ -204,4 +205,19 @@
WHERE t.rn BETWEEN #{offset} AND #{limit}
</select>
<select id="selectPublicPostTotal" resultType="java.lang.Long">
SELECT COUNT(1)
FROM b_post p
<where>
AND p.b_status = 1
<if test="title != null and title != ''">
AND p.b_title LIKE '%' + #{title} + '%'
</if>
<if test="tag != null and tag != ''">
AND p.b_tag LIKE '%' + #{tag} + '%'
</if>
</where>
</select>
</mapper>
+2 -1
View File
@@ -22,7 +22,8 @@
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.24",
"vue-router": "^4.6.3"
"vue-router": "^4.6.3",
"vue3-puzzle-vcode": "^1.1.7"
},
"devDependencies": {
"@antfu/eslint-config": "^6.6.1",
+25
View File
@@ -44,6 +44,9 @@ importers:
vue-router:
specifier: ^4.6.3
version: 4.6.3(vue@3.5.25(typescript@5.9.3))
vue3-puzzle-vcode:
specifier: ^1.1.7
version: 1.1.7
devDependencies:
'@antfu/eslint-config':
specifier: ^6.6.1
@@ -502,36 +505,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -596,56 +605,67 @@ packages:
resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.53.3':
resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.53.3':
resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.53.3':
resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.53.3':
resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.53.3':
resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.53.3':
resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.53.3':
resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.53.3':
resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.53.3':
resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.53.3':
resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.53.3':
resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==}
@@ -2445,6 +2465,9 @@ packages:
peerDependencies:
typescript: '>=5.0.0'
vue3-puzzle-vcode@1.1.7:
resolution: {integrity: sha512-mW780dz7HKjrElnE60CeYSeHGidKBKHoMjTDYfqF21330rTkFOsfDK1FQKZ22MktgMtTEoS/imfpEDlM1cxY/g==}
vue@3.5.25:
resolution: {integrity: sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==}
peerDependencies:
@@ -4980,6 +5003,8 @@ snapshots:
'@vue/language-core': 3.1.8(typescript@5.9.3)
typescript: 5.9.3
vue3-puzzle-vcode@1.1.7: {}
vue@3.5.25(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.25
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref } from 'vue'
import SvgIcon from './SvgIcon.vue'
const emits = defineEmits(['select'])
// 示例 emoji
const emojis = ref<string[]>([
'😊',
'😂',
'😍',
'😎',
'😜',
'🤔',
'😏',
'😢',
'🥺',
'😇',
'😈',
'🤩',
'🥳',
'😱',
'😅',
'😴',
'😷',
'🤐',
'😻',
'🤡',
'💩',
'👻',
'🤖',
'💀',
'👀',
'👍',
'👊',
'✌️',
'💪',
'👏',
])
function handleClickEmoji(emoji: string) {
emits('select', emoji)
}
</script>
<template>
<el-popover trigger="click" position="tl">
<SvgIcon icon="majesticons:emoji-happy-line" class="chat-window-bottom-icon" />
<template #content>
<div class="emoji-container">
<div v-for="(emoji, index) in emojis" :key="index" class="emoji-item" @click="handleClickEmoji(emoji)">
{{ emoji }}
</div>
</div>
</template>
</el-popover>
</template>
<style scoped lang="scss">
.chat-window-bottom-icon {
width: 24px;
height: 24px;
cursor: pointer;
}
.emoji-container {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
padding: 4px;
height: 200px;
overflow-y: auto;
width: 100%;
scrollbar-gutter: stable both-edges;
}
.emoji-item {
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
cursor: pointer;
transition: transform 0.2s ease;
will-change: transform;
}
.emoji-item:hover {
transform: scale(1.2);
z-index: 1;
}
</style>
@@ -1,7 +1,31 @@
import type { PostTag } from '@/types/post'
import type { CreatePostCommentParams, CreatePostCommentResult, PostComment, PostCompany, PostTag, PublicPost, PublicPostDetail, QueryPublicPostParams } from '@/types/post'
import { httpGet, httpPostJson } from '@/request/utils'
// ===== 公共接口 =====
export function getPostTagApi() {
return httpGet<PostTag[]>('/post/tag')
}
export function queryPublicPostApi(params: QueryPublicPostParams) {
return httpPostJson<BasePageResult<PublicPost>>('/post/query', params)
}
export function getPublicPostDetailApi(id: number) {
return httpGet<PublicPostDetail>(`/post/${id}`)
}
export function getPostCompanyById(id: number) {
return httpGet<PostCompany>(`/post/company/${id}`)
}
export function createPostCommentApi(params: CreatePostCommentParams) {
return httpPostJson<CreatePostCommentResult>(`/post/comment/create`, params)
}
export function listPostCommentApi(id: number) {
return httpGet<PostComment[]>(`/post/comment/${id}`)
}
export function deletePostCommentApi(id: number) {
return httpGet<void>(`/post/comment/delete/${id}`)
}
@@ -25,6 +25,10 @@ const router = createRouter({
path: '',
component: () => import('@/views/post/home/index.vue'),
},
{
path: '/post/detail/:id',
component: () => import('@/views/post/detail/index.vue'),
},
],
},
{
@@ -17,6 +17,7 @@ declare module 'vue' {
CustomLoading: typeof import('./../components/CustomLoading.vue')['default']
Directory: typeof import('./../components/wang-editor/modules/directory.vue')['default']
EmailCodeInput: typeof import('./../components/EmailCodeInput.vue')['default']
EmojiPopover: typeof import('./../components/EmojiPopover.vue')['default']
HelloWorld: typeof import('./../components/HelloWorld.vue')['default']
ICarbonAccessibility: typeof import('~icons/carbon/accessibility')['default']
'IFa7Solid:userShield': typeof import('~icons/fa7-solid/user-shield')['default']
@@ -27,3 +27,84 @@ export interface PostCategory {
id: number
name: string
}
export interface PublicPost {
id: number
title: string
tags: string[]
companyId: string
companyName: string
viewCount: number
commentCount: number
activeTime: string
}
export interface QueryPublicPostParams {
currentPage: number
pageSize: number
title: string
tag: string
}
export interface PublicPostDetail {
id: number
title: string
content: string
tags: string[]
activeTime: string
viewCount: number
commentCount: number
companyId: number
}
export interface PostCompany {
id: number
name: string
logo: string
profile: string
areas: string[]
industries: string[]
}
export interface CreatePostCommentParams {
fatherId: number | null
postId: number
content: string
}
export interface CreatePostCommentResult {
id: number
fatherId: number | null
content: string
createId: number
createTime: string
}
export interface PostComment {
id: number
fatherId: number | null
content: string
createTime: string
children: PostComment[]
publishUser: {
id: number
companyId: number
name: string
avatar: string
}
replyUser: {
id: number
companyId: number
name: string
avatar: string
} | null
}
@@ -0,0 +1,46 @@
import dayjs from 'dayjs'
export function formatPostTime(input?: dayjs.ConfigType): string {
if (!input)
return ''
const time = dayjs(input)
if (!time.isValid())
return ''
const now = dayjs()
const diffSeconds = now.diff(time, 'second')
if (diffSeconds < 0) {
// 未来时间兜底
return time.format('YYYY年MM月DD日')
}
// 1 分钟内
if (diffSeconds < 60) {
return '刚刚'
}
const diffMinutes = now.diff(time, 'minute')
if (diffMinutes < 60) {
return `${diffMinutes}分钟前`
}
const diffHours = now.diff(time, 'hour')
if (diffHours < 24) {
return `${diffHours}小时前`
}
const diffDays = now.diff(time, 'day')
if (diffDays < 14) {
return `${diffDays}天前`
}
// 本年
if (time.year() === now.year()) {
return time.format('MM月DD日')
}
// 非本年
return time.format('YYYY年MM月DD日')
}
@@ -0,0 +1,284 @@
<script lang="ts" setup>
import { ElMessage } from 'element-plus'
import { computed, nextTick, ref, watch } from 'vue'
import Vcode from 'vue3-puzzle-vcode'
import { useRouter } from 'vue-router'
import EmojiPopover from '@/components/EmojiPopover.vue'
import { useUserStore } from '@/store/user'
const props = defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '请输入内容...' },
loading: {
type: Boolean,
default: false,
},
})
const emits = defineEmits(['update:modelValue', 'submit'])
const router = useRouter()
const userStore = useUserStore()
const showVcode = ref<boolean>(false)
const inputRef = ref<HTMLElement | null>(null)
const inputValue = ref('')
const isInputFocused = ref(false) // 是否获得焦点
const isEmpty = computed(() => {
if (inputValue.value.trim() === '') {
return true
}
return inputRef.value?.textContent?.trim() === ''
})
watch(
() => props.modelValue,
(newVal) => {
inputValue.value = newVal
if (inputRef.value && inputRef.value.innerHTML !== newVal) {
inputRef.value.innerHTML = newVal
}
},
)
// 焦点事件处理
function handleFocus() {
isInputFocused.value = true
}
function handleBlur() {
isInputFocused.value = false
}
// 输入内容变化处理
function handleInputChange(event: any) {
let html = event.target.innerHTML || ''
if (html.trim() === '<br>' || html.trim() === '<div><br></div>') {
html = ''
event.target.innerHTML = ''
}
inputValue.value = html
emits('update:modelValue', inputValue.value)
}
function handleInputPaste(event: any) {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
// 获取当前光标位置
const selection = window.getSelection()
if (selection !== null && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
// 创建一个新的文本节点
const textNode = document.createTextNode(text)
// 将文本节点插入到当前光标位置
range.deleteContents()
range.insertNode(textNode)
// 重新设置光标位置到插入的文本节点后面
range.setStartAfter(textNode)
range.setEndAfter(textNode)
selection.removeAllRanges()
selection.addRange(range)
if (inputRef.value !== null) {
inputValue.value = inputRef.value.innerHTML
emits('update:modelValue', inputValue.value)
}
}
}
function handleInputKeyDown(event: any) {
// 判断是否是回车键
if (event.key === 'Enter') {
//
}
}
let lastCaretPosition: { node: Node, offset: number } | null = null
// 插入内容函数(兼容现代浏览器)
function insertContent(content: string) {
if (!inputRef.value)
return
const selection = window.getSelection()
if (!selection || !lastCaretPosition)
return
const range = document.createRange()
range.setStart(lastCaretPosition.node, lastCaretPosition.offset)
range.collapse(true)
const textNode = document.createTextNode(content)
range.insertNode(textNode)
range.setStartAfter(textNode)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
inputRef.value.dispatchEvent(new Event('input', { bubbles: true }))
}
// 保存光标位置
function saveCaretPosition() {
const selection = window.getSelection()
if (!selection?.rangeCount)
return
const range = selection.getRangeAt(0)
lastCaretPosition = {
node: range.startContainer,
offset: range.startOffset,
}
}
// 选择emoji
function handleEmojiSelect(emoji: string) {
insertContent(emoji || '')
}
// 发送按钮点击
function handleSend() {
// 判断是否为空内容
if (inputRef.value?.textContent?.trim() === '') {
ElMessage.warning('发送内容不能为空!')
return
}
showVcode.value = true
}
function handleValiteVcodeSuccess() {
showVcode.value = false
// 发送内容给父组件
emits('submit', inputValue.value)
nextTick(() => {
inputValue.value = ''
if (inputRef.value) {
inputRef.value.textContent = ''
}
})
}
function handleToLogin() {
router.push('/login')
}
</script>
<template>
<div v-if="userStore.isLogined" class="custom-input-container" :class="{ 'is-focused': isInputFocused }" v-bind="$attrs">
<div
ref="inputRef"
contenteditable="true"
class="custom-input"
:data-placeholder="placeholder"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInputChange"
@paste="handleInputPaste"
@keydown="handleInputKeyDown"
@click="saveCaretPosition"
@keyup="saveCaretPosition"
/>
<div class="custom-input-footer">
<EmojiPopover @select="handleEmojiSelect" />
<div>
<el-button type="primary" size="small" class="send-button" :disabled="isEmpty" :loading="loading" @click.stop="handleSend">
发送
</el-button>
</div>
</div>
</div>
<div v-else class="nologin-container">
<div class="nologin-title">
尚未登陆,请先登录后发表评论!
</div>
<el-button type="primary" size="small" @click="handleToLogin">
登录/注册
</el-button>
</div>
<Vcode :show="showVcode" @success="handleValiteVcodeSuccess" />
</template>
<style lang="scss" scoped>
.custom-input-container {
display: flex;
flex-direction: column;
border: 1px solid transparent;
background-color: #f2f3f5;
min-height: 100px;
max-height: 160px;
transition: all 0.3s ease;
width: 100%;
}
.nologin-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #f2f3f5;
height: 100px;
width: 100%;
gap: 8px;
.nologin-title {
font-size: 16px;
font-weight: bold;
}
}
.custom-input-container.is-focused {
border-color: rgb(22, 93, 255);
background-color: #fff !important;
}
.custom-input-container:hover {
background-color: #e5e6eb;
}
.custom-input {
flex: 1;
padding: 8px;
font-size: 16px;
box-sizing: border-box;
overflow-y: auto;
border: none;
resize: none;
outline: none;
background-color: transparent;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
cursor: text;
&[data-placeholder]:empty:before {
content: attr(data-placeholder);
color: #999;
pointer-events: none;
display: block;
}
&:focus:empty:before {
content: none;
}
}
.custom-input-footer {
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.send-button {
width: 60px;
}
</style>
@@ -0,0 +1,303 @@
<script lang="ts" setup>
import type { PostComment, PublicPostDetail } from '@/types/post'
import { ElMessage } from 'element-plus'
import { nextTick, ref, watch } from 'vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { createPostCommentApi } from '@/request/api/post'
import { useUserStore } from '@/store/user'
import CommentInput from './comment-input.vue'
const props = defineProps<{
modelValue: PostComment[]
postDetail: PublicPostDetail
}>()
const commentList = ref<PostComment[]>([])
const userStore = useUserStore()
const replyCommentId = ref<number | null>(0)
const replyCommentLoading = ref<boolean>(false)
watch(
() => props.modelValue,
(newVal) => {
commentList.value = newVal
},
{
deep: true,
immediate: true,
},
)
function handleReplyComment(commentId: number) {
if (replyCommentId.value === commentId) {
replyCommentId.value = null
}
else {
replyCommentId.value = commentId
}
nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth',
})
})
}
function findRootComment(commentList: PostComment[], commentId: number | null): PostComment | undefined {
// 使用 for 循环遍历一级评论列表
for (const item of commentList) {
// 如果找到目标评论,直接返回该评论
if (item.id === commentId) {
return item
}
// 如果该评论有子评论,检查子评论
if (item.children) {
const childComment = item.children.find(child => child.id === commentId)
if (childComment) {
return item
}
}
}
// 如果没有找到对应评论,返回 undefined
return undefined
}
function handleReplyCommentSubmit(content: string) {
if (content === '') {
ElMessage.error('评论内容不能为空!')
return
}
replyCommentLoading.value = true
createPostCommentApi({
fatherId: replyCommentId.value,
postId: props.postDetail.id,
content,
})
.then((res) => {
// 显示成功消息
ElMessage.success(res.message)
// 查找根评论
const rootComment = findRootComment(commentList.value, replyCommentId.value)
// 如果根评论存在,并且根评论没有子评论,初始化子评论数组
if (rootComment) {
if (!rootComment.children) {
rootComment.children = [] // 初始化子评论数组
}
// 将新回复添加到子评论中
rootComment.children.push({
id: res.data.id,
fatherId: res.data.fatherId,
content: res.data.content,
createTime: res.data.createTime,
children: [],
publishUser: {
id: userStore.userInfo.user.id,
companyId: userStore.userInfo.company.id,
name: userStore.userInfo.user.name,
avatar: userStore.userInfo.company.logo,
},
replyUser: {
id: rootComment.publishUser.id,
companyId: rootComment.publishUser.companyId,
name: rootComment.publishUser.name,
avatar: rootComment.publishUser.avatar,
},
})
}
else {
commentList.value.push({
id: res.data.id,
fatherId: res.data.fatherId,
content: res.data.content,
createTime: res.data.createTime,
children: [],
publishUser: {
id: userStore.userInfo.user.id,
companyId: userStore.userInfo.company.id,
name: userStore.userInfo.user.name,
avatar: userStore.userInfo.company.logo,
},
replyUser: null,
})
}
})
.finally(() => {
replyCommentId.value = null
replyCommentLoading.value = false
})
}
</script>
<template>
<div v-for="item in commentList" :key="item.id" class="custom-comment">
<div class="custom-comment-left">
<el-avatar class="custom-comment-avatar" :src="item.publishUser.avatar || '/no-avatar.png'" :size="70" />
</div>
<div class="custom-comment-right">
<div class="custom-comment-meta">
<div class="custom-comment-title">
<div class="custom-comment-author">
{{ item.publishUser.name }}
</div>
<div v-if="item.publishUser.companyId === postDetail.companyId" class="custom-comment-author-tag">
作者
</div>
</div>
<div class="custom-comment-time">
{{ item.createTime }}
</div>
</div>
<div class="custom-comment-content" v-html="item.content" />
<div class="custom-comment-actions">
<div class="custom-comment-action" :style="{ color: replyCommentId === item.id ? '#165dff' : '#1d2129' }" @click="handleReplyComment(item.id)">
<SvgIcon icon="mingcute:message-3-line" />
<div>{{ replyCommentId === item.id ? "取消回复" : "回复" }}</div>
</div>
<div class="custom-comment-action flex ml-auto">
<SvgIcon icon="akar-icons:more-horizontal-fill" />
</div>
</div>
<!-- 父级评论输入框 -->
<CommentInput v-if="replyCommentId === item.id" :loading="replyCommentLoading" @submit="handleReplyCommentSubmit" />
<!-- 子评论 -->
<div v-if="item.children && item.children.length > 0" class="custom-comment-children">
<div v-for="child in item.children" :key="child.id" class="custom-comment">
<div class="custom-comment-left">
<el-avatar class="custom-comment-avatar" :src="item.publishUser.avatar || '/no-avatar.png'" :size="70" />
</div>
<div class="custom-comment-right">
<div class="custom-comment-meta">
<div class="custom-comment-title">
<div class="custom-comment-author">
{{ child.publishUser.name }}
</div>
<div v-if="child.publishUser.companyId === postDetail.companyId" class="custom-comment-author-tag">
作者
</div>
<template v-if="child.replyUser && child.fatherId !== item.id">
<div>回复</div>
<div class="custom-comment-author">
{{ child.replyUser.name }}
</div>
</template>
</div>
<div class="custom-comment-time">
{{ child.createTime }}
</div>
</div>
<div class="custom-comment-content">
{{ child.content }}
</div>
<div class="custom-comment-actions">
<div class="custom-comment-action" :style="{ color: replyCommentId === child.id ? '#165dff' : '#1d2129' }" @click="handleReplyComment(child.id)">
<SvgIcon icon="mingcute:message-3-line" />
<div>{{ replyCommentId === child.id ? "取消回复" : "回复" }}</div>
</div>
<div class="custom-comment-action flex ml-auto">
<SvgIcon icon="akar-icons:more-horizontal-fill" />
</div>
</div>
<CommentInput v-if="replyCommentId === child.id" :loading="replyCommentLoading" @submit="handleReplyCommentSubmit" />
</div>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.custom-comment {
margin-top: 1.5rem;
}
.custom-comment:first-child {
margin-top: 0;
}
.custom-comment {
display: flex;
gap: 8px;
line-height: 1.5;
}
.custom-comment-left {
display: flex;
flex-direction: column;
}
.custom-comment-right {
display: flex;
flex-direction: column;
flex: 1;
gap: 8px;
}
.custom-comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.custom-comment-meta {
display: flex;
align-items: center;
justify-content: space-between;
.custom-comment-title {
font-size: 14px;
color: #4e5969;
display: flex;
align-items: center;
gap: 4px;
}
.custom-comment-author {
cursor: pointer;
}
.custom-comment-author-tag {
background-color: #165dff;
color: #ffffff;
font-size: 12px;
border-radius: 4px;
padding: 1px 8px;
}
.custom-comment-time {
font-size: 12px;
color: #86909c;
}
}
.custom-comment-content {
font-size: 14px;
color: #1d2129;
word-break: break-all;
white-space: pre-wrap;
}
.custom-comment-actions {
display: flex;
gap: 8px;
.custom-comment-action {
display: flex;
gap: 4px;
align-items: center;
cursor: pointer;
font-size: 14px;
line-height: 14px;
color: #1d2129;
}
.custom-comment-action:hover {
color: #165dff;
}
}
.custom-comment-children {
margin-left: 20px;
margin-top: 12px;
}
</style>
@@ -0,0 +1,380 @@
<script setup lang="ts">
import type { AnchorMenuItem } from '@/components/anchor-menu/type'
import type { PostComment, PostCompany, PublicPostDetail } from '@/types/post'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AnchorMenu from '@/components/anchor-menu/index.vue'
import CustomEmpty from '@/components/CustomEmpty.vue'
import WandEditor from '@/components/wang-editor/index.vue'
import { createPostCommentApi, getPostCompanyById, getPublicPostDetailApi, listPostCommentApi } from '@/request/api/post'
import { useUserStore } from '@/store/user'
import { formatPostTime } from '@/utils/time'
import CommentInput from './components/comment-input.vue'
import CommentList from './components/comment-list.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const id = Number(route.params.id)
const loading = ref<boolean>(true)
const postCompany = ref<PostCompany>({
id: 0,
name: '',
logo: '',
profile: '',
areas: [],
industries: [],
})
const postDetail = ref<PublicPostDetail>({
id: 0,
title: '',
content: '',
tags: [],
activeTime: '',
viewCount: 0,
commentCount: 0,
companyId: 0,
})
const postComments = ref<PostComment[]>([])
const anchorMenu = ref<AnchorMenuItem[]>([])
const rootComment = ref<string>('')
const rootCommentLoading = ref<boolean>(false)
onMounted(async () => {
if (!id) {
router.push('/post')
return
}
loading.value = true
try {
// 获取帖子详情
const postRes = await getPublicPostDetailApi(id)
if (!postRes.isSuccess) {
throw new Error(postRes.message || '加载帖子失败,请稍后再试')
}
postDetail.value = postRes.data
console.log(postDetail.value)
// 获取公司信息
const companyRes = await getPostCompanyById(postDetail.value.companyId)
if (!companyRes.isSuccess) {
throw new Error(companyRes.message || '加载公司信息失败,请稍后再试')
}
postCompany.value = companyRes.data
console.log(postCompany.value)
// 获取评论信息
const commentRes = await listPostCommentApi(postDetail.value.id)
if (!commentRes.isSuccess) {
throw new Error(commentRes.message || '加载评论信息失败,请稍后再试')
}
postComments.value = commentRes.data
}
catch (err: any) {
console.log(err)
ElMessage.error(err.message || '加载数据失败,请稍后再试')
router.push('/post')
}
finally {
loading.value = false
}
})
function handleUpdateEditorDirectory(menus: AnchorMenuItem[]) {
anchorMenu.value = anchorMenu.value.concat(menus)
}
function handleSubmitRootComment(content: string) {
rootCommentLoading.value = true
createPostCommentApi({
fatherId: null,
postId: postDetail.value.id,
content,
})
.then((res) => {
if (res.isSuccess) {
rootComment.value = ''
ElMessage.success(res.message)
postComments.value.push({
id: res.data.id,
fatherId: res.data.fatherId,
content: res.data.content,
createTime: res.data.createTime,
children: [],
publishUser: {
id: userStore.userInfo.user.id,
companyId: userStore.userInfo.company.id,
name: userStore.userInfo.user.name,
avatar: userStore.userInfo.company.logo,
},
replyUser: null,
})
}
else {
ElMessage.error(res.message || '评论失败,请稍后再试')
}
})
.finally(() => {
rootCommentLoading.value = false
})
}
</script>
<template>
<div class="post">
<div class="post-left">
<el-affix :offset="90">
<el-anchor type="underline">
<AnchorMenu :menu="anchorMenu" />
</el-anchor>
</el-affix>
</div>
<div class="post-center">
<div class="post-main">
<div class="post-title">
{{ postDetail.title }}
</div>
<div class="post-meta">
<div class="post-meta-item">
<SvgIcon icon="mdi:alarm" :size="18" />
<span>{{ formatPostTime(postDetail.activeTime) }} </span>
</div>
<div class="post-meta-item">
<SvgIcon icon="mdi:eye-outline" :size="18" />
<span>{{ postDetail.viewCount }} </span>
</div>
<div class="post-meta-item">
<SvgIcon icon="mdi:comment-outline" :size="16" />
<span>{{ postDetail.commentCount }} </span>
</div>
</div>
</div>
<div class="post-content">
<WandEditor v-model="postDetail.content" mode="view" @update:directory="handleUpdateEditorDirectory" />
</div>
<div class="post-comment">
<div class="divider" />
<div class="post-comment-title">
评论
</div>
<div class="primary-comment">
<el-avatar class="post-comment-avatar" :src="userStore.userInfo.company.logo || '/no-avatar.png'" :size="46" />
<CommentInput v-model="rootComment" :loading="rootCommentLoading" class="post-comment-input" @submit="handleSubmitRootComment" />
</div>
<div class="reply-comment">
<div v-if="postComments.length > 0" class="reply-comment-list">
<CommentList v-model="postComments" :post-detail="postDetail" />
</div>
<CustomEmpty v-else title="暂时没有评论 🤔" :in-container="true" />
</div>
</div>
</div>
<div class="post-right">
<el-affix :offset="70">
<div class="post-company">
<el-avatar :src="postCompany.logo || '/no-avatar.png'" :size="70" class="company-logo" />
<div class="company-info">
<h3 class="company-name">
{{ postCompany.name }}
</h3>
<div v-if="postCompany.areas.length > 0" class="company-meta">
<!-- 成立于 2012 年 · 上海 -->
{{ postCompany.areas.join("/") }}
</div>
<div class="company-desc">
{{ postCompany.profile }}
</div>
<div class="company-tags">
<span v-for="industry in postCompany.industries" :key="industry" class="tag">{{ industry }}</span>
</div>
</div>
</div>
</el-affix>
</div>
</div>
</template>
<style lang="scss" scoped>
.post {
padding: 24px 100px;
display: flex;
background-color: #fff;
.post-left {
width: 240px;
flex-shrink: 0;
.anchor-title {
background-color: #fff;
font-weight: 600;
font-size: 14px;
line-height: 30px;
}
}
.post-center {
flex: 1;
margin: 0px 32px;
.post-main {
display: flex;
flex-direction: column;
background-color: #ffffff;
padding: 0px 16px 12px 16px;
width: 100%;
.post-title {
font-weight: bold;
font-size: 28px;
word-break: break-all;
white-space: normal;
line-height: 1.4;
}
.post-meta {
display: flex;
align-items: center;
margin-top: 8px;
.post-meta-item {
font-size: 14px;
line-height: 20px;
color: #8a919f;
flex-shrink: 0;
display: flex;
align-items: center;
& + .post-meta-item {
margin-left: 12px;
}
span {
margin-left: 6px;
}
}
}
}
.post-content {
padding: 16px;
}
.post-comment {
padding: 0px 16px;
display: flex;
flex-direction: column;
.divider {
height: 1px;
width: 100%;
background-color: #e0e0e0;
margin: 18px 0px;
}
.post-comment-title {
font-size: 18px;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
}
.primary-comment {
display: flex;
gap: 16px;
margin-top: 16px;
.primary-comment-avatar {
min-width: 0;
flex-shrink: 0;
}
.post-comment-input {
flex: 1;
}
}
.reply-comment {
display: flex;
flex-direction: column;
margin-top: 32px;
.reply-comment-list {
margin-bottom: 24px;
}
}
}
}
.post-right {
width: 360px;
flex-shrink: 0;
.post-company {
display: flex;
align-items: flex-start;
padding: 16px;
margin: 0 auto;
border-radius: 6px;
border: 1px solid #ddd;
.company-info {
flex: 1;
margin-left: 12px;
}
.company-name {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #222;
}
.company-meta {
margin: 6px 0 10px;
font-size: 13px;
color: #888;
}
.company-desc {
font-size: 14px;
line-height: 1.6;
color: #555;
margin-bottom: 12px;
}
.company-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
background-color: #eef5ff;
color: #3a7afe;
white-space: nowrap;
}
}
}
}
</style>
@@ -1,18 +1,102 @@
<script lang="ts" setup>
import type { PostTag } from '@/types/base'
import type { PublicPost, QueryPublicPostParams } from '@/types/post'
import { Search } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import CustomEmpty from '@/components/CustomEmpty.vue'
import CustomLoading from '@/components/CustomLoading.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { queryPublicPostApi } from '@/request/api/post'
import { useCommonStore } from '@/store/common'
import { formatPostTime } from '@/utils/time'
const router = useRouter()
const { postTagOptions } = useCommonStore()
const activeNames = ref(['1'])
const selectedTag = ref<PostTag>(postTagOptions[0])
const selectedTag = ref<PostTag | null>(null)
const tableData = ref<PublicPost[]>([])
const page = reactive<Pagination>({
current: 1,
pageSize: 10,
total: 0,
})
const queryForm = reactive<QueryPublicPostParams>({
currentPage: page.current,
pageSize: page.pageSize,
title: '',
tag: '',
})
const loading = ref<boolean>(false)
onMounted(() => {
queryPost()
})
watch(
postTagOptions,
(list) => {
if (list.length && !selectedTag.value) {
selectedTag.value = list[0]
}
},
{ immediate: true },
)
function handleChangeSelectedTag(tag: PostTag) {
selectedTag.value = tag
if (selectedTag.value.id === 0) {
queryForm.tag = ''
}
else {
queryForm.tag = selectedTag.value.name
}
queryForm.title = ''
page.current = 1
queryPost()
}
function queryPost() {
loading.value = true
queryPublicPostApi({
...queryForm,
currentPage: page.current,
pageSize: page.pageSize,
}).then((res) => {
if (res.isSuccess) {
tableData.value = res.data.records
page.total = res.data.total
}
else {
ElMessage.error(res.message || '查询失败,请稍后再试')
}
}).catch((err) => {
console.log(err)
ElMessage.error('查询失败,请稍后再试')
}).finally(() => {
loading.value = false
})
}
function handleSearch() {
page.current = 1
queryPost()
}
function toDetail(data: PublicPost) {
router.push({
path: `/post/detail/${data.id}`,
})
}
</script>
@@ -26,7 +110,7 @@ function handleChangeSelectedTag(tag: PostTag) {
v-for="item in postTagOptions"
:key="item.id"
class="tag-item"
:class="{ active: selectedTag.id === item.id }"
:class="{ active: selectedTag && (selectedTag.id === item.id) }"
@click="handleChangeSelectedTag(item)"
>
<SvgIcon v-if="item.icon" :icon="item.icon" />
@@ -40,55 +124,70 @@ function handleChangeSelectedTag(tag: PostTag) {
<div class="post-main">
<div class="post-main-header">
<el-input
v-model="queryForm.title"
:prefix-icon="Search"
placeholder="搜索" size="large"
placeholder="搜索"
size="large"
@keyup.enter="handleSearch"
/>
<div class="post-pagination">
<el-pagination layout="prev, pager, next,jumper" background :total="1000" />
<el-pagination
v-model:current-page="page.current"
:page-size="page.pageSize"
:total="page.total"
layout="prev, pager, next,jumper"
size="small"
background
@change="queryPost"
/>
</div>
</div>
<div class="post-content">
<!-- <CustomEmpty :in-container="true" /> -->
<CustomLoading v-if="loading" :in-container="true" />
<div class="post-list">
<div v-for="i in 20" :key="i" class="post-item">
<div v-else-if="tableData.length > 0" class="post-list">
<div v-for="item in tableData" :key="item.id" class="post-item">
<div class="post-title-row">
<div class="post-title">
新项目为什么推荐WebFlux,而非SpringMVC?
<div class="post-title" @click="toDetail(item)">
{{ item.title }}
</div>
</div>
<div class="post-meta">
<div class="post-meta-actions">
<div class="post-meta-item">
<div>刘大华</div>
<div class="post-meta-company">
{{ item.companyName }}
</div>
</div>
<div class="post-meta-divider" />
<div class="post-meta-item">
<SvgIcon icon="mdi:eye-outline" />
<span>1.7k</span>
<span>{{ item.viewCount }}</span>
</div>
<div class="post-meta-item">
<SvgIcon icon="iconamoon:comment" />
<span>1.7k</span>
<span>{{ item.commentCount }}</span>
</div>
<div class="post-meta-divider" />
<div class="post-meta-item">
<div>20分钟前</div>
<div>{{ formatPostTime(item.activeTime) }}</div>
</div>
</div>
<div class="post-meta-tags">
<div class="post-tag">
后端
<div v-for="tag in item.tags" :key="tag" class="post-tag">
{{ tag }}
</div>
</div>
</div>
</div>
</div>
<CustomEmpty v-else :in-container="true" />
</div>
</div>
</div>
@@ -96,6 +195,7 @@ function handleChangeSelectedTag(tag: PostTag) {
<style lang="scss" scoped>
.post-wrapper {
height: 100%;
display: flex;
padding: 0px 240px;
background-color: #fff;
@@ -153,8 +253,10 @@ function handleChangeSelectedTag(tag: PostTag) {
}
.post-main {
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
width: 100%;
flex-shrink: 0;
.post-main-header {
position: sticky;
@@ -169,6 +271,7 @@ function handleChangeSelectedTag(tag: PostTag) {
}
.post-content {
flex: 1;
padding-bottom: 36px;
}
@@ -177,19 +280,24 @@ function handleChangeSelectedTag(tag: PostTag) {
flex-direction: column;
.post-item {
padding: 12px 16px;
padding: 6px 16px;
border-bottom: 1px solid #ddd;
.post-title-row {
display: flex;
margin-bottom: 8px;
.post-title {
font-weight: 400;
font-weight: 500;
font-size: 16px;
line-height: 24px;
color: #252933;
width: 100%;
cursor: pointer;
&:hover {
color: #ff6600;
text-decoration: underline;
}
}
}
@@ -200,12 +308,13 @@ function handleChangeSelectedTag(tag: PostTag) {
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
.post-meta-actions {
display: flex;
align-items: center;
.post-meta-item {
font-size: 14px;
font-size: 13px;
line-height: 20px;
color: #8a919f;
flex-shrink: 0;
@@ -215,13 +324,17 @@ function handleChangeSelectedTag(tag: PostTag) {
margin-left: 12px;
}
span {
margin-left: 4px;
margin-left: 6px;
}
.post-meta-company {
color: #0053a1;
}
}
.post-meta-divider {
width: 1px;
height: 12px;
height: 16px;
background-color: #e4e6eb;
margin: 0 12px;
flex-shrink: 0;
File diff suppressed because it is too large. Load diff
@@ -93,9 +93,11 @@ const props = defineProps<{
modelValue: ForumCommentReuslt[];
postDetail: ForumPostDetailResult;
}>();
const emit = defineEmits<{
(event: "update:modelValue", data: ForumCommentReuslt[]): void;
}>();
const commentList = ref<ForumCommentReuslt[]>([]);
watch(