Compare commits

...

40 Commits

Author SHA1 Message Date
Tim
3f152906f2 test: fix PostServiceTest for new PostService deps 2025-09-01 10:53:50 +08:00
Tim
ef71d0b3d4 Merge pull request #798 from nagisa77/feature/vote
feature for vote
2025-09-01 10:28:44 +08:00
Tim
6f80d139ba fix: 投票UI优化 2025-09-01 10:27:02 +08:00
Tim
7454931fa5 Merge pull request #806 from nagisa77/codex/modify-postpoll.vue-for-single-choice-voting
feat: add join button for single polls
2025-09-01 09:54:37 +08:00
Tim
0852664a82 Merge pull request #802 from sivdead/main
feat(model): 为评论和积分历史实体添加逻辑删除功能
2025-09-01 09:54:07 +08:00
Tim
5814fb673a feat: add join button for single polls 2025-09-01 01:06:51 +08:00
Tim
4ee4266e3d Merge pull request #804 from nagisa77/codex/fix-jpasystemexception-for-pollpost
Fix poll multiple property null handling
2025-08-31 14:22:59 +08:00
Tim
6a27fbe1d7 Fix null multiple field for poll posts 2025-08-31 14:22:44 +08:00
Tim
38ff04c358 Merge pull request #803 from nagisa77/codex/add-baseswitch-component-to-voting-post
feat(poll): use BaseSwitch for multiple selection
2025-08-31 14:13:32 +08:00
Tim
fc27200ac1 feat(poll): use BaseSwitch for multiple selection 2025-08-31 14:13:18 +08:00
sivdead
b1998be425 Merge remote-tracking branch 'origin/main' 2025-08-31 14:06:18 +08:00
sivdead
72adc5b232 feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:48 +08:00
sivdead
d24e67de5d feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:10 +08:00
Tim
eefefac236 Merge pull request #801 from nagisa77/codex/add-multi-select-support-for-voting
feat: support multi-option polls
2025-08-31 12:13:54 +08:00
Tim
2f339fdbdb feat: enable multi-option polls 2025-08-31 12:13:41 +08:00
tim
3808becc8b fix: 多选ui 2025-08-31 11:25:34 +08:00
tim
18db4d7317 fix: toolbar 层级修改 2025-08-31 11:14:48 +08:00
Tim
52cbb71945 Merge pull request #800 from nagisa77/codex/refactor-voting-and-lottery-into-components-zk6hvx
refactor: extract poll and lottery components
2025-08-31 11:10:46 +08:00
Tim
39c34a9048 feat: add PostPoll and PostLottery components 2025-08-31 11:10:20 +08:00
tim
4baabf2224 Revert "refactor: extract poll and lottery sections"
This reverts commit 27efc493b2.
2025-08-31 11:09:22 +08:00
Tim
8023183bc6 Merge pull request #799 from nagisa77/codex/refactor-voting-and-lottery-into-components
refactor: extract poll and lottery sections
2025-08-31 11:08:05 +08:00
Tim
27efc493b2 refactor: extract poll and lottery sections 2025-08-31 11:07:49 +08:00
tim
ca6e45a711 fix: 适配夜间模式 2025-08-31 10:55:40 +08:00
tim
803ca9e103 新的通知类型适配 2025-08-31 02:06:32 +08:00
Tim
9d1e12773a Merge pull request #796 from nagisa77/codex/modify-voting-module-components
Refactor poll module and add poll notifications
2025-08-31 01:49:55 +08:00
Tim
5a09934866 refactor poll and lottery forms, add poll notifications 2025-08-31 01:49:37 +08:00
tim
db1d7981c5 fix: checked修改为false 2025-08-31 01:21:52 +08:00
tim
6e1a7c773c fix: 投票模块采用clientOnly 2025-08-31 01:19:48 +08:00
tim
ac4f1064e7 fix: 结束时只显示结果 2025-08-31 01:05:00 +08:00
Tim
4e98fd6a89 Merge pull request #795 from nagisa77/codex/add-real-data-integration-for-voting-page
feat: render poll results with real data
2025-08-31 00:14:38 +08:00
Tim
1bf92ab1ad feat: render poll results with real data 2025-08-31 00:14:12 +08:00
tim
c6ab431c87 fix: 页面适配 2025-08-31 00:04:35 +08:00
Tim
aaa25d5c2f Merge pull request #794 from nagisa77/codex/add-participant-info-to-vote-response-y233h3
feat: return poll option participants
2025-08-30 12:07:01 +08:00
Tim
569531b462 feat: add poll vote repository 2025-08-30 12:06:11 +08:00
tim
c3ae97f8ba Revert "feat: track poll votes"
This reverts commit 23582934fa.
2025-08-30 12:05:35 +08:00
Tim
a57f3e6406 Merge pull request #793 from nagisa77/codex/add-participant-info-to-vote-response
feat: expose poll option participants
2025-08-30 12:03:34 +08:00
Tim
23582934fa feat: track poll votes 2025-08-30 12:03:17 +08:00
Tim
5adee4db0e Merge pull request #792 from nagisa77/codex/add-voting-feature-to-post
feat: add poll post support
2025-08-29 23:56:41 +08:00
Tim
a2ccc95b4e feat: add poll post support 2025-08-29 23:56:03 +08:00
Tim
dc5eb5a637 Merge pull request #791 from nagisa77/codex/add-voting-post-type
feat: add poll post type
2025-08-29 22:37:12 +08:00
29 changed files with 1384 additions and 532 deletions

View File

@@ -44,7 +44,7 @@ public class PostController {
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getQuestion(), req.getOptions());
req.getOptions(), req.getMultiple());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
@@ -94,7 +94,7 @@ public class PostController {
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) {
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}

View File

@@ -8,9 +8,10 @@ import java.util.Map;
@Data
public class PollDto {
private String question;
private List<String> options;
private Map<Integer, Integer> votes;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
private boolean multiple;
}

View File

@@ -27,7 +27,7 @@ public class PostRequest {
private LocalDateTime startTime;
private LocalDateTime endTime;
// fields for poll posts
private String question;
private List<String> options;
private Boolean multiple;
}

View File

@@ -6,19 +6,23 @@ import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ReactionDto;
import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.AuthorDto;
import com.openisle.model.CommentSort;
import com.openisle.model.Post;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.User;
import com.openisle.model.PollVote;
import com.openisle.service.CommentService;
import com.openisle.service.ReactionService;
import com.openisle.service.SubscriptionService;
import com.openisle.repository.PollVoteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** Mapper responsible for converting posts into DTOs. */
@@ -34,6 +38,7 @@ public class PostMapper {
private final UserMapper userMapper;
private final TagMapper tagMapper;
private final CategoryMapper categoryMapper;
private final PollVoteRepository pollVoteRepository;
public PostSummaryDto toSummaryDto(Post post) {
PostSummaryDto dto = new PostSummaryDto();
@@ -98,11 +103,15 @@ public class PostMapper {
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setQuestion(pp.getQuestion());
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream()
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
}
}

View File

@@ -5,6 +5,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -41,4 +45,7 @@ public class Comment {
@Column
private LocalDateTime pinnedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -40,6 +40,12 @@ public enum NotificationType {
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** Someone participated in your poll */
POLL_VOTE,
/** Your poll post has concluded */
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */

View File

@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "point_histories")
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -46,4 +50,7 @@ public class PointHistory {
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -15,10 +15,6 @@ import java.util.*;
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class PollPost extends Post {
@Column(nullable = false)
private String question;
@ElementCollection
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
@Column(name = "option_text")
@@ -36,6 +32,12 @@ public class PollPost extends Post {
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column
private Boolean multiple = false;
@Column
private LocalDateTime endTime;
@Column
private boolean resultAnnounced = false;
}

View File

@@ -0,0 +1,28 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"}))
@Getter
@Setter
@NoArgsConstructor
public class PollVote {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id")
private PollPost post;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "option_index", nullable = false)
private int optionIndex;
}

View File

@@ -2,6 +2,7 @@ package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import com.openisle.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
@@ -12,4 +13,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment);
}

View File

@@ -3,5 +3,11 @@ package com.openisle.repository;
import com.openisle.model.PollPost;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
import java.util.List;
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
}

View File

@@ -0,0 +1,10 @@
package com.openisle.repository;
import com.openisle.model.PollVote;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PollVoteRepository extends JpaRepository<PollVote, Long> {
List<PollVote> findByPostId(Long postId);
}

View File

@@ -11,6 +11,7 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService;
import com.openisle.model.Role;
@@ -37,6 +38,7 @@ public class CommentService {
private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final ImageUploader imageUploader;
@Transactional
@@ -235,10 +237,14 @@ public class CommentService {
for (Comment c : replies) {
deleteCommentCascade(c);
}
// 逻辑删除相关的积分历史记录
pointHistoryRepository.findByComment(comment).forEach(pointHistoryRepository::delete);
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
commentRepository.delete(comment);
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}

View File

@@ -10,6 +10,7 @@ import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
import com.openisle.model.PollVote;
import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository;
@@ -22,6 +23,7 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j;
@@ -57,6 +59,7 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
private final SubscriptionService subscriptionService;
@@ -82,6 +85,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
CommentService commentService,
@@ -102,6 +106,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
this.commentService = commentService;
@@ -130,6 +135,15 @@ public class PostService {
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
applicationContext.getBean(PostService.class).finalizeLottery(lp.getId());
}
for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
}
public PublishMode getPublishMode() {
@@ -172,8 +186,8 @@ public class PostService {
Integer pointCost,
LocalDateTime startTime,
LocalDateTime endTime,
String question,
java.util.List<String> options) {
java.util.List<String> options,
Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
@@ -212,9 +226,9 @@ public class PostService {
throw new IllegalArgumentException("At least two options required");
}
PollPost pp = new PollPost();
pp.setQuestion(question);
pp.setOptions(options);
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else {
post = new Post();
@@ -264,6 +278,11 @@ public class PostService {
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
scheduledFinalizations.put(pp.getId(), future);
}
return post;
}
@@ -285,7 +304,7 @@ public class PostService {
}
@Transactional
public PollPost votePoll(Long postId, String username, int optionIndex) {
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
PollPost post = pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
@@ -296,12 +315,47 @@ public class PostService {
if (post.getParticipants().contains(user)) {
throw new IllegalArgumentException("User already voted");
}
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
if (optionIndices == null || optionIndices.isEmpty()) {
throw new IllegalArgumentException("No options selected");
}
java.util.Set<Integer> unique = new java.util.HashSet<>(optionIndices);
for (int optionIndex : unique) {
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
}
}
post.getParticipants().add(user);
post.getVotes().merge(optionIndex, 1, Integer::sum);
return pollPostRepository.save(post);
for (int optionIndex : unique) {
post.getVotes().merge(optionIndex, 1, Integer::sum);
PollVote vote = new PollVote();
vote.setPost(post);
vote.setUser(user);
vote.setOptionIndex(optionIndex);
pollVoteRepository.save(vote);
}
PollPost saved = pollPostRepository.save(post);
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
}
return saved;
}
@Transactional
public void finalizePoll(Long postId) {
scheduledFinalizations.remove(postId);
pollPostRepository.findById(postId).ifPresent(pp -> {
if (pp.isResultAnnounced()) {
return;
}
pp.setResultAnnounced(true);
pollPostRepository.save(pp);
if (pp.getAuthor() != null) {
notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null);
}
for (User participant : pp.getParticipants()) {
notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null);
}
});
}
@Transactional

View File

@@ -0,0 +1,11 @@
-- Add logical delete support for comments and point_histories tables
-- Add deleted_at column to comments table
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add deleted_at column to point_histories table
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add index for better performance on logical delete queries
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);

View File

@@ -76,7 +76,7 @@ class PostControllerTest {
post.setTags(Set.of(tag));
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
when(postService.viewPost(eq(1L), any())).thenReturn(post);
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
@@ -187,7 +187,7 @@ class PostControllerTest {
.andExpect(status().isBadRequest());
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
any(), any(), any(), any(), any(), any(), any());
any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test

View File

@@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test;
@@ -24,10 +25,11 @@ class CommentServiceTest {
ReactionRepository reactionRepo = mock(ReactionRepository.class);
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
NotificationRepository nRepo = mock(NotificationRepository.class);
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
ImageUploader imageUploader = mock(ImageUploader.class);
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader);
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, imageUploader);
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);

View File

@@ -22,6 +22,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -37,7 +39,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -69,6 +71,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -84,7 +88,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -122,6 +126,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -137,7 +143,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -146,7 +152,7 @@ class PostServiceTest {
assertThrows(RateLimitException.class,
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null, null));
null, null, null, null, null, null, null, null, null));
}
@Test
@@ -156,6 +162,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -171,7 +179,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);

View File

@@ -34,6 +34,7 @@
--page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa;
--poll-option-button-background-color: rgb(218, 218, 218);
}
[data-theme='dark'] {
@@ -61,6 +62,7 @@
--blockquote-text-color: #999;
--article-info-background-color: #747373;
--activity-card-background-color: #585858;
--poll-option-button-background-color: #3a3a3a;
}
:root[data-frosted='off'] {
@@ -91,7 +93,7 @@ body {
.vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important;
z-index: 2000;
z-index: 20;
}
.vditor-panel {

View File

@@ -0,0 +1,189 @@
<template>
<div class="lottery-section">
<AvatarCropper
:src="data.tempPrizeIcon"
:show="data.showPrizeCropper"
@close="data.showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<span class="prize-row-title">奖品图片</span>
<label class="prize-container">
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
<i v-else class="fa-solid fa-image default-prize-icon"></i>
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-name-row">
<span class="prize-row-title">奖品描述</span>
<BaseInput v-model="data.prizeDescription" placeholder="奖品描述" />
</div>
<div class="prize-count-row">
<span class="prize-row-title">奖品数量</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.prizeCount"
min="1"
/>
</div>
</div>
<div class="prize-point-row">
<span class="prize-row-title">参与所需积分</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="data.pointCost"
min="0"
max="100"
/>
</div>
</div>
<div class="prize-time-row">
<span class="prize-row-title">抽奖结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseImage from '~/components/BaseImage.vue'
import BaseInput from '~/components/BaseInput.vue'
import { watch } from 'vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
props.data.tempPrizeIcon = reader.result
props.data.showPrizeCropper = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
props.data.prizeIconFile = file
props.data.prizeIcon = url
}
watch(
() => props.data.prizeCount,
(val) => {
if (!val || val < 1) props.data.prizeCount = 1
},
)
watch(
() => props.data.pointCost,
(val) => {
if (val === undefined || val === null || val < 0) props.data.pointCost = 0
if (val > 100) props.data.pointCost = 100
},
)
</script>
<style scoped>
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.prize-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.prize-row,
.prize-name-row,
.prize-count-row,
.prize-point-row,
.prize-time-row {
display: flex;
flex-direction: column;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background-color: var(--lottery-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.default-prize-icon {
font-size: 30px;
opacity: 0.1;
color: var(--text-color);
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-input {
display: flex;
align-items: center;
}
.prize-count-input-field {
width: 50px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="poll-section">
<div class="poll-options-row">
<span class="poll-row-title">投票选项</span>
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
<i
v-if="data.options.length > 2"
class="fa-solid fa-xmark remove-option-icon"
@click="removeOption(idx)"
></i>
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>
<div class="poll-time-row">
<span class="poll-row-title">投票结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
<div class="poll-multiple-row">
<span class="poll-row-title">多选</span>
<BaseSwitch v-model="data.multiple" />
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import BaseInput from '~/components/BaseInput.vue'
import BaseSwitch from '~/components/BaseSwitch.vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
})
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const addOption = () => {
props.data.options.push('')
}
const removeOption = (idx) => {
if (props.data.options.length > 2) {
props.data.options.splice(idx, 1)
}
}
</script>
<style scoped>
.poll-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.poll-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.poll-option-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.remove-option-icon {
cursor: pointer;
}
.add-option {
color: var(--primary-color);
cursor: pointer;
width: fit-content;
margin-top: 5px;
}
.poll-options-row,
.poll-time-row {
display: flex;
flex-direction: column;
}
.poll-multiple-row {
display: flex;
align-items: center;
gap: 10px;
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="post-prize-container" v-if="lottery">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<BaseImage
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<BaseImage
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useIsMobile } from '~/utils/screen'
const props = defineProps({
lottery: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const isMobile = useIsMobile()
const loggedIn = computed(() => authState.loggedIn)
const lotteryParticipants = computed(() => props.lottery?.participants || [])
const lotteryWinners = computed(() => props.lottery?.winners || [])
const lotteryEnded = computed(() => {
if (!props.lottery || !props.lottery.endTime) return false
return new Date(props.lottery.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.lottery || !props.lottery.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.lottery?.endTime,
() => {
if (props.lottery && props.lottery.endTime) startCountdown()
},
)
onMounted(() => {
if (props.lottery && props.lottery.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('已参与抽奖')
emit('refresh')
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
<div class="poll-option-text">{{ opt }}</div>
<div class="poll-option-progress-info">
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
</div>
</div>
<div class="poll-option-progress">
<div
class="poll-option-progress-bar"
:style="{ width: pollPercentages[idx] + '%' }"
></div>
</div>
<div class="poll-participants">
<BaseImage
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
</div>
<div v-else>
<div class="poll-title-section">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="toggleOption(idx)"
>
<input
type="checkbox"
:checked="selectedOptions.includes(idx)"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
<div class="multi-selection-container">
<div class="join-poll-button" @click="submitMultiPoll">
<i class="fas fa-check"></i> 确认投票
</div>
</div>
</template>
<template v-else>
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="selectOption(idx)"
>
<input
type="radio"
:checked="selectedOption === idx"
name="poll-option"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
<div class="single-selection-container">
<div class="join-poll-button" @click="submitSinglePoll">
<i class="fas fa-check"></i> 确认投票
</div>
</div>
</template>
</div>
</div>
<div class="poll-info">
<div class="total-votes">{{ pollParticipants.length }}</div>
<div class="total-votes-title">投票人</div>
</div>
</div>
<div class="poll-bottom-container">
<div
v-if="showPollResult && !pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = false"
>
<i class="fas fa-chevron-left"></i> 投票
</div>
<div
v-else-if="!pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = true"
>
<i class="fas fa-chart-bar"></i> 结果
</div>
<div v-else-if="pollEnded" class="poll-option-hint">
<i class="fas fa-stopwatch"></i> 投票已结束
</div>
<div v-else class="poll-option-hint">
<i class="fas fa-stopwatch"></i> 您已投票等待结束查看结果
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
const props = defineProps({
poll: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
const pollPercentages = computed(() =>
props.poll
? props.poll.options.map((_, idx) => {
const c = pollVotes.value[idx] || 0
return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0
})
: [],
)
const pollEnded = computed(() => {
if (!props.poll || !props.poll.endTime) return false
return new Date(props.poll.endTime).getTime() <= Date.now()
})
const hasVoted = computed(() => {
if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
})
watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.poll || !props.poll.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.poll.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.poll?.endTime,
() => {
if (props.poll && props.poll.endTime) startCountdown()
},
)
onMounted(() => {
if (props.poll && props.poll.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const voteOption = async (idx) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?option=${idx}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
const selectedOption = ref(null)
const selectOption = (idx) => {
selectedOption.value = idx
}
const submitSinglePoll = async () => {
if (selectedOption.value === null) {
toast.error('请选择一个选项')
return
}
await voteOption(selectedOption.value)
}
const selectedOptions = ref([])
const toggleOption = (idx) => {
const i = selectedOptions.value.indexOf(idx)
if (i >= 0) {
selectedOptions.value.splice(i, 1)
} else {
selectedOptions.value.push(idx)
}
}
const submitMultiPoll = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
if (!selectedOptions.value.length) {
toast.error('请选择至少一个选项')
return
}
const params = selectedOptions.value.map((o) => `option=${o}`).join('&')
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?${params}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-poll-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
border-radius: 8px;
background-color: var(--poll-option-button-background-color);
cursor: pointer;
width: fit-content;
}
.poll-top-container {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--normal-border-color);
}
.poll-options-container {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 4;
border-right: 1px solid var(--normal-border-color);
}
.poll-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100px;
}
.total-votes {
font-size: 40px;
font-weight: bold;
opacity: 0.8;
}
.total-votes-title {
font-size: 18px;
opacity: 0.5;
}
.poll-option {
margin-bottom: 10px;
margin-right: 10px;
cursor: pointer;
display: flex;
align-items: center;
}
.poll-option-result {
margin-bottom: 10px;
margin-right: 10px;
gap: 5px;
display: flex;
flex-direction: column;
}
.poll-option-input {
margin-right: 10px;
width: 18px;
height: 18px;
accent-color: var(--primary-color);
border-radius: 50%;
border: 2px solid var(--primary-color);
}
.poll-option-text {
font-size: 18px;
}
.poll-bottom-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.poll-left-time {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.poll-left-time-title {
font-size: 13px;
opacity: 0.7;
}
.poll-left-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.poll-option-progress {
position: relative;
background-color: rgb(187, 187, 187);
height: 20px;
border-radius: 5px;
overflow: hidden;
}
.poll-option-progress-bar {
background-color: var(--primary-color);
height: 100%;
}
.poll-option-info-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.poll-option-progress-info {
font-size: 12px;
line-height: 20px;
color: var(--text-color);
}
.multi-selection-container,
.single-selection-container {
margin-top: 30px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.multi-selection-title,
.single-selection-title {
font-size: 13px;
color: var(--text-color);
}
.poll-title-section {
display: flex;
gap: 30px;
flex-direction: row;
margin-bottom: 20px;
}
.poll-option-title {
font-size: 18px;
font-weight: bold;
}
.poll-left-time {
font-size: 18px;
}
.info-icon {
margin-right: 5px;
}
.join-poll-button {
padding: 5px 10px;
background-color: var(--primary-color);
color: white;
border-radius: 8px;
cursor: pointer;
}
.join-poll-button:hover {
background-color: var(--primary-color-hover);
}
.poll-participants {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.poll-participant-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@@ -33,6 +33,7 @@ export default {
return [
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
{ id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' },
]
}

View File

@@ -70,6 +70,10 @@
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
<i
v-else-if="article.type === 'POLL'"
class="fa-solid fa-square-poll-vertical poll-icon"
></i>
{{ article.title }}
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
@@ -542,7 +546,8 @@ const sanitizeDescription = (text) => stripMarkdown(text)
}
.pinned-icon,
.lottery-icon {
.lottery-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);
}

View File

@@ -195,6 +195,44 @@
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POLL_VOTE'">
<NotificationContainer :item="item" :markRead="markRead">
有用户参与了你的投票贴
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POLL_RESULT_OWNER'">
<NotificationContainer :item="item" :markRead="markRead">
你的投票帖
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
已出结果
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POLL_RESULT_PARTICIPANT'">
<NotificationContainer :item="item" :markRead="markRead">
你参与的投票帖
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
已出结果
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -676,6 +714,12 @@ const formatType = (t) => {
return '帖子被删除'
case 'POST_FEATURED':
return '文章被精选'
case 'POLL_VOTE':
return '有人参与你的投票'
case 'POLL_RESULT_OWNER':
return '发布的投票结果已公布'
case 'POLL_RESULT_PARTICIPANT':
return '参与的投票结果已公布'
default:
return t
}

View File

@@ -35,71 +35,21 @@
</div>
</div>
</div>
<div v-if="postType === 'LOTTERY'" class="lottery-section">
<AvatarCropper
:src="tempPrizeIcon"
:show="showPrizeCropper"
@close="showPrizeCropper = false"
@crop="onPrizeCropped"
/>
<div class="prize-row">
<span class="prize-row-title">奖品图片</span>
<label class="prize-container">
<BaseImage v-if="prizeIcon" :src="prizeIcon" class="prize-preview" alt="prize" />
<i v-else class="fa-solid fa-image default-prize-icon"></i>
<div class="prize-overlay">上传奖品图片</div>
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
</label>
</div>
<div class="prize-name-row">
<span class="prize-row-title">奖品描述</span>
<BaseInput v-model="prizeDescription" placeholder="奖品描述" />
</div>
<div class="prize-count-row">
<span class="prize-row-title">奖品数量</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="prizeCount"
min="1"
/>
</div>
</div>
<div class="prize-point-row">
<span class="prize-row-title">参与所需积分</span>
<div class="prize-count-input">
<input
class="prize-count-input-field"
type="number"
v-model.number="pointCost"
min="0"
max="100"
/>
</div>
</div>
<div class="prize-time-row">
<span class="prize-row-title">抽奖结束时间</span>
<client-only>
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
<PollForm v-if="postType === 'POLL'" :data="poll" />
</div>
</div>
</template>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import { computed, onMounted, ref, reactive } from 'vue'
import CategorySelect from '~/components/CategorySelect.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import LotteryForm from '~/components/LotteryForm.vue'
import PollForm from '~/components/PollForm.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
@@ -110,47 +60,27 @@ const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const prizeIcon = ref('')
const prizeIconFile = ref(null)
const tempPrizeIcon = ref('')
const showPrizeCropper = ref(false)
const prizeName = ref('')
const prizeCount = ref(1)
const prizeDescription = ref('')
const pointCost = ref(0)
const endTime = ref(null)
const lottery = reactive({
prizeIcon: '',
prizeIconFile: null,
tempPrizeIcon: '',
showPrizeCropper: false,
prizeName: '',
prizeDescription: '',
prizeCount: 1,
pointCost: 0,
endTime: null,
})
const poll = reactive({
options: ['', ''],
endTime: null,
multiple: false,
})
const startTime = ref(null)
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
const onPrizeIconChange = (e) => {
const file = e.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = () => {
tempPrizeIcon.value = reader.result
showPrizeCropper.value = true
}
reader.readAsDataURL(file)
}
}
const onPrizeCropped = ({ file, url }) => {
prizeIconFile.value = file
prizeIcon.value = url
}
watch(prizeCount, (val) => {
if (!val || val < 1) prizeCount.value = 1
})
watch(pointCost, (val) => {
if (val === undefined || val === null || val < 0) pointCost.value = 0
if (val > 100) pointCost.value = 100
})
const loadDraft = async () => {
const token = getToken()
if (!token) return
@@ -180,15 +110,19 @@ const clearPost = async () => {
selectedCategory.value = ''
selectedTags.value = []
postType.value = 'NORMAL'
prizeIcon.value = ''
prizeIconFile.value = null
tempPrizeIcon.value = ''
showPrizeCropper.value = false
prizeDescription.value = ''
prizeCount.value = 1
pointCost.value = 0
endTime.value = null
lottery.prizeIcon = ''
lottery.prizeIconFile = null
lottery.tempPrizeIcon = ''
lottery.showPrizeCropper = false
lottery.prizeName = ''
lottery.prizeDescription = ''
lottery.prizeCount = 1
lottery.pointCost = 0
lottery.endTime = null
startTime.value = null
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
// 删除草稿
const token = getToken()
@@ -318,35 +252,45 @@ const submitPost = async () => {
return
}
if (postType.value === 'LOTTERY') {
if (!prizeIcon.value) {
if (!lottery.prizeIcon) {
toast.error('请上传奖品图片')
return
}
if (!prizeCount.value || prizeCount.value < 1) {
if (!lottery.prizeCount || lottery.prizeCount < 1) {
toast.error('奖品数量必须大于0')
return
}
if (!prizeDescription.value) {
if (!lottery.prizeDescription) {
toast.error('请输入奖品描述')
return
}
if (!endTime.value) {
if (!lottery.endTime) {
toast.error('请选择抽奖结束时间')
return
}
if (pointCost.value < 0 || pointCost.value > 100) {
if (lottery.pointCost < 0 || lottery.pointCost > 100) {
toast.error('参与积分需在0到100之间')
return
}
}
if (postType.value === 'POLL') {
if (poll.options.length < 2 || poll.options.some((o) => !o.trim())) {
toast.error('请填写至少两个投票选项')
return
}
if (!poll.endTime) {
toast.error('请选择投票结束时间')
return
}
}
try {
const token = getToken()
await ensureTags(token)
isWaitingPosting.value = true
let prizeIconUrl = prizeIcon.value
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
let prizeIconUrl = lottery.prizeIcon
if (postType.value === 'LOTTERY' && lottery.prizeIconFile) {
const form = new FormData()
form.append('file', prizeIconFile.value)
form.append('file', lottery.prizeIconFile)
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
@@ -372,17 +316,21 @@ const submitPost = async () => {
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
options: postType.value === 'POLL' ? poll.options : undefined,
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined,
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: postType.value === 'POLL'
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
})
const data = await res.json()
@@ -517,123 +465,6 @@ const submitPost = async () => {
padding-bottom: 50px;
}
.lottery-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.prize-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.prize-row {
display: flex;
flex-direction: column;
}
.prize-name-row {
display: flex;
flex-direction: column;
}
.prize-container {
position: relative;
width: 100px;
height: 100px;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
background-color: var(--lottery-background-color);
display: flex;
align-items: center;
justify-content: center;
}
.default-prize-icon {
font-size: 30px;
opacity: 0.1;
color: var(--text-color);
}
.prize-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-input {
display: none;
}
.prize-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.prize-container:hover .prize-overlay {
opacity: 1;
}
.prize-count-row,
.prize-time-row {
display: flex;
flex-direction: column;
}
.prize-count-input {
display: flex;
align-items: center;
}
.prize-name-input {
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
margin-left: 10px;
font-size: 16px;
color: var(--text-color);
}
.prize-count-input-field {
width: 50px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
}
@media (max-width: 768px) {
.new-post-page {
width: calc(100vw - 20px);

View File

@@ -94,84 +94,10 @@
</div>
</div>
<div v-if="lottery" class="post-prize-container">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<BaseImage
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<BaseImage
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
<ClientOnly>
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
</ClientOnly>
<div v-if="closed" class="post-close-container">该帖子已关闭内容仅供阅读无法进行互动</div>
<ClientOnly>
@@ -259,6 +185,8 @@ import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { toast } from '~/main'
@@ -314,7 +242,6 @@ useHead(() => ({
if (import.meta.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
})
}
@@ -325,44 +252,7 @@ const loggedIn = computed(() => authState.loggedIn)
const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null)
const countdown = ref('00:00:00')
let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => {
if (!lottery.value || !lottery.value.endTime) return false
return new Date(lottery.value.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const updateCountdown = () => {
if (!lottery.value || !lottery.value.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(lottery.value.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
}
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const poll = ref(null)
const articleMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
@@ -523,7 +413,7 @@ watchEffect(() => {
rssExcluded.value = data.rssExcluded
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown()
poll.value = data.poll || null
})
// 404 客户端跳转
@@ -814,25 +704,6 @@ const unsubscribePost = async () => {
}
}
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('已参与抽奖')
await refreshPost()
} else {
toast.error(data.error || '操作失败')
}
}
const fetchCommentSorts = () => {
return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
@@ -1276,139 +1147,6 @@ onMounted(async () => {
position: relative;
}
.post-prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.post-page-main-container {
width: calc(100% - 20px);
@@ -1459,10 +1197,5 @@ onMounted(async () => {
.loading-container {
width: 100%;
}
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -25,6 +25,9 @@ const iconMap = {
POINT_REDEEM: 'fas fa-gift',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
POLL_VOTE: 'fas fa-square-poll-vertical',
POLL_RESULT_OWNER: 'fas fa-flag-checkered',
POLL_RESULT_PARTICIPANT: 'fas fa-flag-checkered',
MENTION: 'fas fa-at',
POST_DELETED: 'fas fa-trash',
POST_FEATURED: 'fas fa-star',
@@ -210,6 +213,21 @@ function createFetchNotifications() {
}
},
})
} else if (
n.type === 'POLL_VOTE' ||
n.type === 'POLL_RESULT_OWNER' ||
n.type === 'POLL_RESULT_PARTICIPANT'
) {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
arr.push({
...n,