Merge pull request #423 from WilliamColton/main

增加积分系统
This commit is contained in:
Tim
2025-08-07 20:04:03 +08:00
committed by GitHub
13 changed files with 308 additions and 91 deletions
@@ -7,6 +7,7 @@ import com.openisle.mapper.CommentMapper;
import com.openisle.service.CaptchaService; import com.openisle.service.CaptchaService;
import com.openisle.service.CommentService; import com.openisle.service.CommentService;
import com.openisle.service.LevelService; import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -26,6 +27,7 @@ public class CommentController {
private final LevelService levelService; private final LevelService levelService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final PointService pointService;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
private boolean captchaEnabled; private boolean captchaEnabled;
@@ -45,6 +47,7 @@ public class CommentController {
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment); CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName())); dto.setReward(levelService.awardForComment(auth.getName()));
dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
log.debug("createComment succeeded for comment {}", comment.getId()); log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@@ -5,12 +5,7 @@ import com.openisle.dto.PostRequest;
import com.openisle.dto.PostSummaryDto; import com.openisle.dto.PostSummaryDto;
import com.openisle.mapper.PostMapper; import com.openisle.mapper.PostMapper;
import com.openisle.model.Post; import com.openisle.model.Post;
import com.openisle.service.CaptchaService; import com.openisle.service.*;
import com.openisle.service.DraftService;
import com.openisle.service.LevelService;
import com.openisle.service.PostService;
import com.openisle.service.SubscriptionService;
import com.openisle.service.UserVisitService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -25,12 +20,12 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PostController { public class PostController {
private final PostService postService; private final PostService postService;
private final SubscriptionService subscriptionService;
private final LevelService levelService; private final LevelService levelService;
private final CaptchaService captchaService; private final CaptchaService captchaService;
private final DraftService draftService; private final DraftService draftService;
private final UserVisitService userVisitService; private final UserVisitService userVisitService;
private final PostMapper postMapper; private final PostMapper postMapper;
private final PointService pointService;
@Value("${app.captcha.enabled:false}") @Value("${app.captcha.enabled:false}")
private boolean captchaEnabled; private boolean captchaEnabled;
@@ -48,6 +43,7 @@ public class PostController {
draftService.deleteDraft(auth.getName()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName())); dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName()));
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@@ -6,8 +6,8 @@ import com.openisle.mapper.ReactionMapper;
import com.openisle.model.Reaction; import com.openisle.model.Reaction;
import com.openisle.model.ReactionType; import com.openisle.model.ReactionType;
import com.openisle.service.LevelService; import com.openisle.service.LevelService;
import com.openisle.service.PointService;
import com.openisle.service.ReactionService; import com.openisle.service.ReactionService;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -20,6 +20,7 @@ public class ReactionController {
private final ReactionService reactionService; private final ReactionService reactionService;
private final LevelService levelService; private final LevelService levelService;
private final ReactionMapper reactionMapper; private final ReactionMapper reactionMapper;
private final PointService pointService;
/** /**
* Get all available reaction types. * Get all available reaction types.
@@ -31,27 +32,29 @@ public class ReactionController {
@PostMapping("/posts/{postId}/reactions") @PostMapping("/posts/{postId}/reactions")
public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId, public ResponseEntity<ReactionDto> reactToPost(@PathVariable Long postId,
@RequestBody ReactionRequest req, @RequestBody ReactionRequest req,
Authentication auth) { Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType()); Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) { if (reaction == null) {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
ReactionDto dto = reactionMapper.toDto(reaction); ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName())); dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfPost(auth.getName(), postId);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
@PostMapping("/comments/{commentId}/reactions") @PostMapping("/comments/{commentId}/reactions")
public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId, public ResponseEntity<ReactionDto> reactToComment(@PathVariable Long commentId,
@RequestBody ReactionRequest req, @RequestBody ReactionRequest req,
Authentication auth) { Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType()); Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) { if (reaction == null) {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
ReactionDto dto = reactionMapper.toDto(reaction); ReactionDto dto = reactionMapper.toDto(reaction);
dto.setReward(levelService.awardForReaction(auth.getName())); dto.setReward(levelService.awardForReaction(auth.getName()));
pointService.awardForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.ok(dto); return ResponseEntity.ok(dto);
} }
} }
@@ -17,5 +17,6 @@ public class CommentDto {
private List<CommentDto> replies; private List<CommentDto> replies;
private List<ReactionDto> reactions; private List<ReactionDto> reactions;
private int reward; private int reward;
private int pointReward;
} }
@@ -27,5 +27,6 @@ public class PostSummaryDto {
private List<AuthorDto> participants; private List<AuthorDto> participants;
private boolean subscribed; private boolean subscribed;
private int reward; private int reward;
private int pointReward;
} }
@@ -25,6 +25,7 @@ public class UserDto {
private long likesReceived; private long likesReceived;
private boolean subscribed; private boolean subscribed;
private int experience; private int experience;
private int point;
private int currentLevel; private int currentLevel;
private int nextLevelExp; private int nextLevelExp;
} }
@@ -53,6 +53,7 @@ public class UserMapper {
dto.setLikesSent(reactionService.countLikesSent(user.getUsername())); dto.setLikesSent(reactionService.countLikesSent(user.getUsername()));
dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername())); dto.setLikesReceived(reactionService.countLikesReceived(user.getUsername()));
dto.setExperience(user.getExperience()); dto.setExperience(user.getExperience());
dto.setPoint(user.getPoint());
dto.setCurrentLevel(levelService.getLevel(user.getExperience())); dto.setCurrentLevel(levelService.getLevel(user.getExperience()));
dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience())); dto.setNextLevelExp(levelService.nextLevelExp(user.getExperience()));
if (viewer != null) { if (viewer != null) {
@@ -0,0 +1,37 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
/** Daily experience gain counts for a user. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "point_logs",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "log_date"}))
public class PointLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "log_date", nullable = false)
private LocalDate logDate;
@Column(name = "post_count", nullable = false)
private int postCount;
@Column(name = "comment_count", nullable = false)
private int commentCount;
@Column(name = "reaction_count", nullable = false)
private int reactionCount;
}
@@ -5,9 +5,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import com.openisle.model.Role; import java.time.LocalDateTime;
/** /**
* Simple user entity with basic fields and a role. * Simple user entity with basic fields and a role.
@@ -44,6 +43,9 @@ public class User {
@Column(nullable = false) @Column(nullable = false)
private int experience = 0; private int experience = 0;
@Column(nullable = false)
private int point = 0;
@Column(length = 1000) @Column(length = 1000)
private String introduction; private String introduction;
@@ -0,0 +1,12 @@
package com.openisle.repository;
import com.openisle.model.PointLog;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
public interface PointLogRepository extends JpaRepository<PointLog, Long> {
Optional<PointLog> findByUserAndLogDate(User user, LocalDate logDate);
}
@@ -0,0 +1,119 @@
package com.openisle.service;
import com.openisle.model.PointLog;
import com.openisle.model.User;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@Service
@RequiredArgsConstructor
public class PointService {
private final UserRepository userRepository;
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
public int awardForPost(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
return addPoint(user, 30);
}
private PointLog getTodayLog(User user) {
LocalDate today = LocalDate.now();
return pointLogRepository.findByUserAndLogDate(user, today)
.orElseGet(() -> {
PointLog log = new PointLog();
log.setUser(user);
log.setLogDate(today);
log.setPostCount(0);
log.setCommentCount(0);
log.setReactionCount(0);
return pointLogRepository.save(log);
});
}
private int addPoint(User user, int amount) {
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
return amount;
}
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
public int awardForComment(String commenterName, Long postId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
PointLog log = getTodayLog(commenter);
if (log.getCommentCount() > 3) {
isTheRewardCapped = true;
}
// 如果发帖人与评论者是同一个,则只计算单次加分
if (poster.getId().equals(commenter.getId())) {
if (isTheRewardCapped) {
return 0;
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
return addPoint(commenter, 10);
}
} else {
addPoint(poster, 10);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10);
}
}
}
// 需要考虑点赞者和发帖人是同一个的情况
public int awardForReactionOfPost(String reactionerName, Long postId) {
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果发帖人与点赞者是同一个,则不加分
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
// 如果不是同一个,则为发帖人加分
return addPoint(poster, 10);
}
// 考虑点赞者和评论者是同一个的情况
public int awardForReactionOfComment(String reactionerName, Long commentId) {
// 根据帖子id找到评论者
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
// 获取点赞者信息
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
// 如果评论者与点赞者是同一个,则不加分
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
// 如果不是同一个,则为发帖人加分
return addPoint(commenter, 10);
}
}
+42 -21
View File
@@ -1,15 +1,15 @@
<template> <template>
<div class="new-post-page"> <div class="new-post-page">
<div class="new-post-form"> <div class="new-post-form">
<input class="post-title-input" v-model="title" placeholder="标题" /> <input class="post-title-input" v-model="title" placeholder="标题"/>
<div class="post-editor-container"> <div class="post-editor-container">
<PostEditor v-model="content" v-model:loading="isAiLoading" :disabled="!isLogin" /> <PostEditor v-model="content" v-model:loading="isAiLoading" :disabled="!isLogin" />
<LoginOverlay v-if="!isLogin" /> <LoginOverlay v-if="!isLogin" />
</div> </div>
<div class="post-options"> <div class="post-options">
<div class="post-options-left"> <div class="post-options-left">
<CategorySelect v-model="selectedCategory" /> <CategorySelect v-model="selectedCategory"/>
<TagSelect v-model="selectedTags" creatable /> <TagSelect v-model="selectedTags" creatable/>
</div> </div>
<div class="post-options-right"> <div class="post-options-right">
<div class="post-clear" @click="clearPost"> <div class="post-clear" @click="clearPost">
@@ -24,12 +24,13 @@
存草稿 存草稿
</div> </div>
<div <div
v-if="!isWaitingPosting" v-if="!isWaitingPosting"
class="post-submit" class="post-submit"
:class="{ disabled: !isLogin }" :class="{ disabled: !isLogin }"
@click="submitPost" @click="submitPost"
>发布</div> >发布
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div> </div>
<div v-else class="post-submit-loading"><i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
</div> </div>
</div> </div>
</div> </div>
@@ -37,17 +38,17 @@
</template> </template>
<script> <script>
import { ref, onMounted, computed } from 'vue' import {ref, onMounted, computed} from 'vue'
import PostEditor from '../components/PostEditor.vue' import PostEditor from '../components/PostEditor.vue'
import CategorySelect from '../components/CategorySelect.vue' import CategorySelect from '../components/CategorySelect.vue'
import TagSelect from '../components/TagSelect.vue' import TagSelect from '../components/TagSelect.vue'
import { API_BASE_URL, toast } from '../main' import {API_BASE_URL, toast} from '../main'
import { getToken, authState } from '../utils/auth' import {getToken, authState} from '../utils/auth'
import LoginOverlay from '../components/LoginOverlay.vue' import LoginOverlay from '../components/LoginOverlay.vue'
export default { export default {
name: 'NewPostPageView', name: 'NewPostPageView',
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay }, components: {PostEditor, CategorySelect, TagSelect, LoginOverlay},
setup() { setup() {
const title = ref('') const title = ref('')
const content = ref('') const content = ref('')
@@ -62,7 +63,7 @@ export default {
if (!token) return if (!token) return
try { try {
const res = await fetch(`${API_BASE_URL}/api/drafts/me`, { const res = await fetch(`${API_BASE_URL}/api/drafts/me`, {
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok && res.status !== 204) { if (res.ok && res.status !== 204) {
const data = await res.json() const data = await res.json()
@@ -144,7 +145,7 @@ export default {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ name, description: '' }) body: JSON.stringify({name, description: ''})
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -179,7 +180,7 @@ export default {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
}, },
body: JSON.stringify({ text: content.value }) body: JSON.stringify({text: content.value})
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -232,11 +233,19 @@ export default {
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
if (data.reward && data.reward > 0) { const reward = Math.max(0, Number(data?.reward) || 0); // 经验值
toast.success(`发布成功,获得 ${data.reward} 经验值`) const points = Math.max(0, Number(data?.pointReward) || 0); // 积分值
if (reward && points) {
toast.success(`发布成功,获得 ${reward} 经验值、${points} 积分值`);
} else if (reward) {
toast.success(`发布成功,获得 ${reward} 经验值`);
} else if (points) {
toast.success(`发布成功,获得 ${points} 积分值`);
} else { } else {
toast.success('发布成功') toast.success('发布成功');
} }
if (data.id) { if (data.id) {
window.location.href = `/posts/${data.id}` window.location.href = `/posts/${data.id}`
} }
@@ -251,7 +260,19 @@ export default {
isWaitingPosting.value = false isWaitingPosting.value = false
} }
} }
return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin } return {
title,
content,
selectedCategory,
selectedTags,
submitPost,
saveDraft,
clearPost,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin
}
} }
} }
</script> </script>
@@ -301,7 +322,6 @@ export default {
} }
.post-clear { .post-clear {
color: var(--primary-color); color: var(--primary-color);
cursor: pointer; cursor: pointer;
@@ -329,6 +349,7 @@ export default {
.post-submit:hover { .post-submit:hover {
background-color: var(--primary-color-hover); background-color: var(--primary-color-hover);
} }
.post-submit.disabled:hover { .post-submit.disabled:hover {
background-color: var(--primary-color-disabled); background-color: var(--primary-color-disabled);
} }
+76 -56
View File
@@ -8,8 +8,8 @@
<div class="article-title-container-left"> <div class="article-title-container-left">
<div class="article-title">{{ title }}</div> <div class="article-title">{{ title }}</div>
<div class="article-info-container"> <div class="article-info-container">
<ArticleCategory :category="category" /> <ArticleCategory :category="category"/>
<ArticleTags :tags="tags" /> <ArticleTags :tags="tags"/>
</div> </div>
</div> </div>
<div class="article-title-container-right"> <div class="article-title-container-right">
@@ -64,12 +64,12 @@
</div> </div>
<CommentEditor @submit="postComment" :loading="isWaitingPostingComment" :disabled="!loggedIn" <CommentEditor @submit="postComment" :loading="isWaitingPostingComment" :disabled="!loggedIn"
:show-login-overlay="!loggedIn" /> :show-login-overlay="!loggedIn"/>
<div class="comment-config-container"> <div class="comment-config-container">
<div class="comment-sort-container"> <div class="comment-sort-container">
<div class="comment-sort-title">Sort by: </div> <div class="comment-sort-title">Sort by:</div>
<Dropdown v-model="commentSort" :fetch-options="fetchCommentSorts" /> <Dropdown v-model="commentSort" :fetch-options="fetchCommentSorts"/>
</div> </div>
</div> </div>
@@ -77,10 +77,10 @@
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else class="comments-container"> <div v-else class="comments-container">
<BaseTimeline :items="comments"> <BaseTimeline :items="comments">
<template #item="{ item }"> <template #item="{ item }">
<CommentItem :key="item.id" :comment="item" :level="0" :default-show-replies="item.openReplies" <CommentItem :key="item.id" :comment="item" :level="0" :default-show-replies="item.openReplies"
@deleted="onCommentDeleted" /> @deleted="onCommentDeleted"/>
</template> </template>
</BaseTimeline> </BaseTimeline>
</div> </div>
@@ -92,7 +92,7 @@
<div v-else class="scroller-time">{{ scrollerTopTime }}</div> <div v-else class="scroller-time">{{ scrollerTopTime }}</div>
<div class="scroller-middle"> <div class="scroller-middle">
<input type="range" class="scroller-range" :max="totalPosts" :min="1" v-model.number="currentIndex" <input type="range" class="scroller-range" :max="totalPosts" :min="1" v-model.number="currentIndex"
@input="onSliderInput" /> @input="onSliderInput"/>
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div> <div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
</div> </div>
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div> <div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
@@ -100,14 +100,14 @@
</div> </div>
</div> </div>
<vue-easy-lightbox :visible="lightboxVisible" :index="lightboxIndex" :imgs="lightboxImgs" <vue-easy-lightbox :visible="lightboxVisible" :index="lightboxIndex" :imgs="lightboxImgs"
@hide="lightboxVisible = false" /> @hide="lightboxVisible = false"/>
</div> </div>
</template> </template>
<script> <script>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import {ref, computed, onMounted, onBeforeUnmount, nextTick, watch} from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router' import {useRoute} from 'vue-router'
import CommentItem from '../components/CommentItem.vue' import CommentItem from '../components/CommentItem.vue'
import CommentEditor from '../components/CommentEditor.vue' import CommentEditor from '../components/CommentEditor.vue'
import BaseTimeline from '../components/BaseTimeline.vue' import BaseTimeline from '../components/BaseTimeline.vue'
@@ -115,19 +115,30 @@ import ArticleTags from '../components/ArticleTags.vue'
import ArticleCategory from '../components/ArticleCategory.vue' import ArticleCategory from '../components/ArticleCategory.vue'
import ReactionsGroup from '../components/ReactionsGroup.vue' import ReactionsGroup from '../components/ReactionsGroup.vue'
import DropdownMenu from '../components/DropdownMenu.vue' import DropdownMenu from '../components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '../utils/markdown' import {renderMarkdown, handleMarkdownClick, stripMarkdownLength} from '../utils/markdown'
import { API_BASE_URL, toast } from '../main' import {API_BASE_URL, toast} from '../main'
import { getToken, authState } from '../utils/auth' import {getToken, authState} from '../utils/auth'
import TimeManager from '../utils/time' import TimeManager from '../utils/time'
import { hatch } from 'ldrs' import {hatch} from 'ldrs'
import { useRouter } from 'vue-router' import {useRouter} from 'vue-router'
import { isMobile } from '../utils/screen' import {isMobile} from '../utils/screen'
import Dropdown from '../components/Dropdown.vue' import Dropdown from '../components/Dropdown.vue'
hatch.register() hatch.register()
export default { export default {
name: 'PostPageView', name: 'PostPageView',
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup, DropdownMenu, VueEasyLightbox, Dropdown }, components: {
CommentItem,
CommentEditor,
BaseTimeline,
ArticleTags,
ArticleCategory,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
Dropdown
},
setup() { setup() {
const route = useRoute() const route = useRoute()
const postId = route.params.id const postId = route.params.id
@@ -173,7 +184,7 @@ export default {
if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription) if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription)
window.removeEventListener('scroll', updateCurrentIndex) window.removeEventListener('scroll', updateCurrentIndex)
}) })
const lightboxVisible = ref(false) const lightboxVisible = ref(false)
const lightboxIndex = ref(0) const lightboxIndex = ref(0)
const lightboxImgs = ref([]) const lightboxImgs = ref([])
@@ -183,19 +194,19 @@ export default {
const articleMenuItems = computed(() => { const articleMenuItems = computed(() => {
const items = [] const items = []
if (isAuthor.value || isAdmin.value) { if (isAuthor.value || isAdmin.value) {
items.push({ text: '编辑文章', onClick: () => editPost() }) items.push({text: '编辑文章', onClick: () => editPost()})
items.push({ text: '删除文章', color: 'red', onClick: () => deletePost() }) items.push({text: '删除文章', color: 'red', onClick: () => deletePost()})
} }
if (isAdmin.value) { if (isAdmin.value) {
if (pinnedAt.value) { if (pinnedAt.value) {
items.push({ text: '取消置顶', onClick: () => unpinPost() }) items.push({text: '取消置顶', onClick: () => unpinPost()})
} else { } else {
items.push({ text: '置顶', onClick: () => pinPost() }) items.push({text: '置顶', onClick: () => pinPost()})
} }
} }
if (isAdmin.value && status.value === 'PENDING') { if (isAdmin.value && status.value === 'PENDING') {
items.push({ text: '通过审核', onClick: () => approvePost() }) items.push({text: '通过审核', onClick: () => approvePost()})
items.push({ text: '驳回', color: 'red', onClick: () => rejectPost() }) items.push({text: '驳回', color: 'red', onClick: () => rejectPost()})
} }
return items return items
}) })
@@ -204,12 +215,12 @@ export default {
const items = [] const items = []
if (mainContainer.value) { if (mainContainer.value) {
const main = mainContainer.value.querySelector('.info-content-container') const main = mainContainer.value.querySelector('.info-content-container')
if (main) items.push({ el: main, top: getTop(main) }) if (main) items.push({el: main, top: getTop(main)})
for (const c of comments.value) { for (const c of comments.value) {
const el = document.getElementById('comment-' + c.id) const el = document.getElementById('comment-' + c.id)
if (el) { if (el) {
items.push({ el, top: getTop(el) }) items.push({el, top: getTop(el)})
} }
} }
// 根据 top 排序,防止评论异步插入后顺序错乱 // 根据 top 排序,防止评论异步插入后顺序错乱
@@ -292,7 +303,7 @@ export default {
isWaitingFetchingPost.value = true; isWaitingFetchingPost.value = true;
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, { const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
headers: { Authorization: token ? `Bearer ${token}` : '' } headers: {Authorization: token ? `Bearer ${token}` : ''}
}) })
isWaitingFetchingPost.value = false; isWaitingFetchingPost.value = false;
if (!res.ok) { if (!res.ok) {
@@ -321,22 +332,22 @@ export default {
const totalPosts = computed(() => comments.value.length + 1) const totalPosts = computed(() => comments.value.length + 1)
const lastReplyTime = computed(() => const lastReplyTime = computed(() =>
comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value comments.value.length ? comments.value[comments.value.length - 1].time : postTime.value
) )
const firstReplyTime = computed(() => const firstReplyTime = computed(() =>
comments.value.length ? comments.value[0].time : postTime.value comments.value.length ? comments.value[0].time : postTime.value
) )
const scrollerTopTime = computed(() => const scrollerTopTime = computed(() =>
commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value commentSort.value === 'OLDEST' ? postTime.value : firstReplyTime.value
) )
watch( watch(
() => comments.value.length, () => comments.value.length,
async () => { async () => {
await nextTick() await nextTick()
gatherPostItems() gatherPostItems()
updateCurrentIndex() updateCurrentIndex()
} }
) )
const updateCurrentIndex = () => { const updateCurrentIndex = () => {
@@ -360,13 +371,13 @@ export default {
const target = postItems.value[index - 1] const target = postItems.value[index - 1]
if (target) { if (target) {
const top = getTop(target) - headerHeight - 20 // 20 for beauty const top = getTop(target) - headerHeight - 20 // 20 for beauty
window.scrollTo({ top, behavior: 'auto' }) window.scrollTo({top, behavior: 'auto'})
} }
} }
const postComment = async (text, clear) => { const postComment = async (text, clear) => {
if (!text.trim()) return if (!text.trim()) return
console.debug('Posting comment', { postId, text }) console.debug('Posting comment', {postId, text})
isWaitingPostingComment.value = true isWaitingPostingComment.value = true
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -377,8 +388,8 @@ export default {
try { try {
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments`, { const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
body: JSON.stringify({ content: text }) body: JSON.stringify({content: text})
}) })
console.debug('Post comment response status', res.status) console.debug('Post comment response status', res.status)
if (res.ok) { if (res.ok) {
@@ -386,11 +397,20 @@ export default {
console.debug('Post comment response data', data) console.debug('Post comment response data', data)
await fetchComments() await fetchComments()
clear() clear()
if (data.reward && data.reward > 0) {
toast.success(`评论成功,获得 ${data.reward} 经验值`) const reward = Math.max(0, Number(data?.reward) || 0) // 经验值
const points = Math.max(0, Number(data?.pointReward) || 0) // 积分值
if (reward && points) {
toast.success(`评论成功,获得 ${reward} 经验值、${points} 积分值`)
} else if (reward) {
toast.success(`评论成功,获得 ${reward} 经验值`)
} else if (points) {
toast.success(`评论成功,获得 ${points} 积分值`)
} else { } else {
toast.success('评论成功') toast.success('评论成功')
} }
} else if (res.status === 429) { } else if (res.status === 429) {
toast.error('评论过于频繁,请稍后再试') toast.error('评论过于频繁,请稍后再试')
} else { } else {
@@ -418,7 +438,7 @@ export default {
} }
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, { const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
subscribed.value = true subscribed.value = true
@@ -433,7 +453,7 @@ export default {
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/approve`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
status.value = 'PUBLISHED' status.value = 'PUBLISHED'
@@ -448,7 +468,7 @@ export default {
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/pin`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = new Date().toISOString() pinnedAt.value = new Date().toISOString()
@@ -463,7 +483,7 @@ export default {
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/unpin`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
pinnedAt.value = null pinnedAt.value = null
@@ -472,7 +492,7 @@ export default {
toast.error('操作失败') toast.error('操作失败')
} }
} }
const editPost = () => { const editPost = () => {
router.push(`/posts/${postId}/edit`) router.push(`/posts/${postId}/edit`)
} }
@@ -485,7 +505,7 @@ export default {
} }
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, { const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
toast.success('已删除') toast.success('已删除')
@@ -500,7 +520,7 @@ export default {
if (!token) return if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, { const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/reject`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
status.value = 'REJECTED' status.value = 'REJECTED'
@@ -519,7 +539,7 @@ export default {
const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, { const res = await fetch(`${API_BASE_URL}/api/subscriptions/posts/${postId}`, {
method: 'DELETE', method: 'DELETE',
headers: { Authorization: `Bearer ${token}` } headers: {Authorization: `Bearer ${token}`}
}) })
if (res.ok) { if (res.ok) {
subscribed.value = false subscribed.value = false
@@ -531,19 +551,19 @@ export default {
const fetchCommentSorts = () => { const fetchCommentSorts = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, {id: 'NEWEST', name: '最新', icon: 'fas fa-clock'},
{ id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start' }, {id: 'OLDEST', name: '最旧', icon: 'fas fa-hourglass-start'},
// { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' } // { id: 'MOST_INTERACTIONS', name: '最多互动', icon: 'fas fa-fire' }
]) ])
} }
const fetchComments = async () => { const fetchComments = async () => {
isFetchingComments.value = true isFetchingComments.value = true
console.debug('Fetching comments', { postId, sort: commentSort.value }) console.debug('Fetching comments', {postId, sort: commentSort.value})
try { try {
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`, { const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/comments?sort=${commentSort.value}`, {
headers: { Authorization: token ? `Bearer ${token}` : '' } headers: {Authorization: token ? `Bearer ${token}` : ''}
}) })
console.debug('Fetch comments response status', res.status) console.debug('Fetch comments response status', res.status)
if (res.ok) { if (res.ok) {
@@ -572,7 +592,7 @@ export default {
const el = document.getElementById('comment-' + id) const el = document.getElementById('comment-' + id)
if (el) { if (el) {
const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty const top = el.getBoundingClientRect().top + window.scrollY - headerHeight - 20 // 20 for beauty
window.scrollTo({ top, behavior: 'smooth' }) window.scrollTo({top, behavior: 'smooth'})
el.classList.add('comment-highlight') el.classList.add('comment-highlight')
setTimeout(() => el.classList.remove('comment-highlight'), 4000) setTimeout(() => el.classList.remove('comment-highlight'), 4000)
} }