From 55dd36bd24e2347c5a4aecdbd2d092fdde89922e Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:36:36 +0800 Subject: [PATCH 01/23] feat: add poll post type --- .../openisle/controller/PostController.java | 15 ++++++- .../main/java/com/openisle/dto/PollDto.java | 16 +++++++ .../java/com/openisle/dto/PostRequest.java | 3 ++ .../java/com/openisle/dto/PostSummaryDto.java | 1 + .../java/com/openisle/mapper/PostMapper.java | 12 +++++ .../java/com/openisle/model/PollPost.java | 41 +++++++++++++++++ .../java/com/openisle/model/PostType.java | 3 +- .../repository/PollPostRepository.java | 7 +++ .../com/openisle/service/PostService.java | 45 ++++++++++++++++++- 9 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/java/com/openisle/dto/PollDto.java create mode 100644 backend/src/main/java/com/openisle/model/PollPost.java create mode 100644 backend/src/main/java/com/openisle/repository/PollPostRepository.java diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index aa1707ff1..90c892834 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -3,6 +3,7 @@ package com.openisle.controller; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostRequest; import com.openisle.dto.PostSummaryDto; +import com.openisle.dto.PollDto; import com.openisle.mapper.PostMapper; import com.openisle.model.Post; import com.openisle.service.*; @@ -42,7 +43,8 @@ public class PostController { req.getTitle(), req.getContent(), req.getTagIds(), req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), req.getPrizeCount(), req.getPointCost(), - req.getStartTime(), req.getEndTime()); + req.getStartTime(), req.getEndTime(), + req.getQuestion(), req.getOptions()); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); @@ -86,6 +88,17 @@ public class PostController { return ResponseEntity.ok().build(); } + @GetMapping("/{id}/poll/progress") + public ResponseEntity pollProgress(@PathVariable Long id) { + return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll()); + } + + @PostMapping("/{id}/poll/vote") + public ResponseEntity vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) { + postService.votePoll(id, auth.getName(), option); + return ResponseEntity.ok().build(); + } + @GetMapping public List listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryIds", required = false) List categoryIds, diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java new file mode 100644 index 000000000..3612d60ed --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -0,0 +1,16 @@ +package com.openisle.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Data +public class PollDto { + private String question; + private List options; + private Map votes; + private LocalDateTime endTime; + private List participants; +} diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 1fe02669a..7e6619e2a 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -26,5 +26,8 @@ public class PostRequest { private Integer pointCost; private LocalDateTime startTime; private LocalDateTime endTime; + // fields for poll posts + private String question; + private List options; } diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index 665c590f7..48205bc0e 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -31,6 +31,7 @@ public class PostSummaryDto { private int pointReward; private PostType type; private LotteryDto lottery; + private PollDto poll; private boolean rssExcluded; private boolean closed; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index ad1a826da..5577dcdb8 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -5,9 +5,11 @@ import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostSummaryDto; import com.openisle.dto.ReactionDto; import com.openisle.dto.LotteryDto; +import com.openisle.dto.PollDto; 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.service.CommentService; import com.openisle.service.ReactionService; @@ -93,5 +95,15 @@ public class PostMapper { l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); dto.setLottery(l); } + + 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())); + dto.setPoll(p); + } } } diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java new file mode 100644 index 000000000..c19978c21 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -0,0 +1,41 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.*; + +@Entity +@Table(name = "poll_posts") +@Getter +@Setter +@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") + private List options = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id")) + @MapKeyColumn(name = "option_index") + @Column(name = "vote_count") + private Map votes = new HashMap<>(); + + @ManyToMany + @JoinTable(name = "poll_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set participants = new HashSet<>(); + + @Column + private LocalDateTime endTime; +} diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java index d14a28701..b8becaca0 100644 --- a/backend/src/main/java/com/openisle/model/PostType.java +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -2,5 +2,6 @@ package com.openisle.model; public enum PostType { NORMAL, - LOTTERY + LOTTERY, + POLL } diff --git a/backend/src/main/java/com/openisle/repository/PollPostRepository.java b/backend/src/main/java/com/openisle/repository/PollPostRepository.java new file mode 100644 index 000000000..d0985fe4b --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PollPostRepository.java @@ -0,0 +1,7 @@ +package com.openisle.repository; + +import com.openisle.model.PollPost; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PollPostRepository extends JpaRepository { +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 993c19c20..296133b4c 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -9,8 +9,10 @@ import com.openisle.model.Category; import com.openisle.model.Comment; import com.openisle.model.NotificationType; import com.openisle.model.LotteryPost; +import com.openisle.model.PollPost; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; +import com.openisle.repository.PollPostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; @@ -54,6 +56,7 @@ public class PostService { private final CategoryRepository categoryRepository; private final TagRepository tagRepository; private final LotteryPostRepository lotteryPostRepository; + private final PollPostRepository pollPostRepository; private PublishMode publishMode; private final NotificationService notificationService; private final SubscriptionService subscriptionService; @@ -78,6 +81,7 @@ public class PostService { CategoryRepository categoryRepository, TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, + PollPostRepository pollPostRepository, NotificationService notificationService, SubscriptionService subscriptionService, CommentService commentService, @@ -97,6 +101,7 @@ public class PostService { this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; + this.pollPostRepository = pollPostRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; this.commentService = commentService; @@ -166,7 +171,9 @@ public class PostService { Integer prizeCount, Integer pointCost, LocalDateTime startTime, - LocalDateTime endTime) { + LocalDateTime endTime, + String question, + java.util.List options) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { @@ -200,6 +207,15 @@ public class PostService { lp.setStartTime(startTime); lp.setEndTime(endTime); post = lp; + } else if (actualType == PostType.POLL) { + if (options == null || options.size() < 2) { + throw new IllegalArgumentException("At least two options required"); + } + PollPost pp = new PollPost(); + pp.setQuestion(question); + pp.setOptions(options); + pp.setEndTime(endTime); + post = pp; } else { post = new Post(); } @@ -212,6 +228,8 @@ public class PostService { post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); + } else if (post instanceof PollPost) { + post = pollPostRepository.save((PollPost) post); } else { post = postRepository.save(post); } @@ -261,6 +279,31 @@ public class PostService { } } + public PollPost getPoll(Long postId) { + return pollPostRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + } + + @Transactional + public PollPost votePoll(Long postId, String username, int optionIndex) { + PollPost post = pollPostRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) { + throw new IllegalStateException("Poll has ended"); + } + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + if (post.getParticipants().contains(user)) { + throw new IllegalArgumentException("User already voted"); + } + 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); + } + @Transactional public void finalizeLottery(Long postId) { log.info("start to finalizeLottery for {}", postId); From a2ccc95b4e291c59e14d550c7d347c4d2940eb6d Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Fri, 29 Aug 2025 23:56:03 +0800 Subject: [PATCH 02/23] feat: add poll post support --- frontend_nuxt/components/PostTypeSelect.vue | 1 + frontend_nuxt/pages/index.vue | 7 +- frontend_nuxt/pages/new-post.vue | 85 +++++++++++- frontend_nuxt/pages/posts/[id]/index.vue | 146 +++++++++++++++++++- 4 files changed, 232 insertions(+), 7 deletions(-) diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue index 3d01eacd7..fd713628c 100644 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -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' }, ] } diff --git a/frontend_nuxt/pages/index.vue b/frontend_nuxt/pages/index.vue index dfd51e349..e2be72a60 100644 --- a/frontend_nuxt/pages/index.vue +++ b/frontend_nuxt/pages/index.vue @@ -70,6 +70,10 @@ + {{ article.title }} @@ -542,7 +546,8 @@ const sanitizeDescription = (text) => stripMarkdown(text) } .pinned-icon, -.lottery-icon { +.lottery-icon, +.poll-icon { margin-right: 4px; color: var(--primary-color); } diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 1252330a0..43935c1af 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -85,6 +85,30 @@ +
+
+ 投票问题 + +
+
+ 投票选项 +
+ + +
+
添加选项
+
+
+ 投票结束时间 + + + +
+
@@ -120,6 +144,8 @@ const prizeDescription = ref('') const pointCost = ref(0) const endTime = ref(null) const startTime = ref(null) +const pollQuestion = ref('') +const pollOptions = ref(['', '']) const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' } const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -151,6 +177,14 @@ watch(pointCost, (val) => { if (val > 100) pointCost.value = 100 }) +const addOption = () => { + pollOptions.value.push('') +} + +const removeOption = (idx) => { + if (pollOptions.value.length > 2) pollOptions.value.splice(idx, 1) +} + const loadDraft = async () => { const token = getToken() if (!token) return @@ -189,6 +223,8 @@ const clearPost = async () => { pointCost.value = 0 endTime.value = null startTime.value = null + pollQuestion.value = '' + pollOptions.value = ['', ''] // 删除草稿 const token = getToken() @@ -339,6 +375,20 @@ const submitPost = async () => { return } } + if (postType.value === 'POLL') { + if (!pollQuestion.value.trim()) { + toast.error('请输入投票问题') + return + } + if (pollOptions.value.length < 2 || pollOptions.value.some((o) => !o.trim())) { + toast.error('请填写至少两个投票选项') + return + } + if (!endTime.value) { + toast.error('请选择投票结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -375,12 +425,14 @@ const submitPost = async () => { prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined, prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined, + question: postType.value === 'POLL' ? pollQuestion.value : undefined, + options: postType.value === 'POLL' ? pollOptions.value : undefined, startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined, // 将时间转换为 UTC+8.5 时区 todo: 需要优化 endTime: - postType.value === 'LOTTERY' + postType.value === 'LOTTERY' || postType.value === 'POLL' ? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : undefined, }), @@ -517,7 +569,8 @@ const submitPost = async () => { padding-bottom: 50px; } -.lottery-section { +.lottery-section, +.poll-section { margin-top: 20px; display: flex; flex-direction: column; @@ -526,7 +579,8 @@ const submitPost = async () => { margin-bottom: 200px; } -.prize-row-title { +.prize-row-title, +.poll-row-title { font-size: 16px; color: var(--text-color); font-weight: bold; @@ -612,6 +666,31 @@ const submitPost = async () => { color: var(--text-color); } +.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-question-row, +.poll-time-row { + display: flex; + flex-direction: column; +} + .prize-count-input-field { width: 50px; height: 30px; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index c6bd9d440..63be402bd 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -171,6 +171,38 @@ +
+
{{ poll.question }}
+
+
+
{{ opt }}
+
+
+
{{ pollVotes[idx] || 0 }} 票
+
+
+ 投票 +
+
+
+
+ +
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -325,6 +357,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 poll = ref(null) const countdown = ref('00:00:00') let countdownTimer = null const lotteryParticipants = computed(() => lottery.value?.participants || []) @@ -337,12 +370,36 @@ const hasJoined = computed(() => { if (!loggedIn.value) return false return lotteryParticipants.value.some((p) => p.id === Number(authState.userId)) }) +const pollParticipants = computed(() => poll.value?.participants || []) +const pollVotes = computed(() => poll.value?.votes || {}) +const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) +const pollPercentages = computed(() => + poll.value + ? poll.value.options.map((_, idx) => { + const c = pollVotes.value[idx] || 0 + return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0 + }) + : [], +) +const pollEnded = computed(() => { + if (!poll.value || !poll.value.endTime) return false + return new Date(poll.value.endTime).getTime() <= Date.now() +}) +const hasVoted = computed(() => { + if (!loggedIn.value) return false + return pollParticipants.value.some((p) => p.id === Number(authState.userId)) +}) +const currentEndTime = computed(() => { + if (lottery.value && lottery.value.endTime) return lottery.value.endTime + if (poll.value && poll.value.endTime) return poll.value.endTime + return null +}) const updateCountdown = () => { - if (!lottery.value || !lottery.value.endTime) { + if (!currentEndTime.value) { countdown.value = '00:00:00' return } - const diff = new Date(lottery.value.endTime).getTime() - Date.now() + const diff = new Date(currentEndTime.value).getTime() - Date.now() if (diff <= 0) { countdown.value = '00:00:00' if (countdownTimer) { @@ -523,7 +580,9 @@ 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 + if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) + startCountdown() }) // 404 客户端跳转 @@ -833,6 +892,25 @@ const joinLottery = async () => { } } +const voteOption = async (idx) => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, { + 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' }, @@ -1286,6 +1364,68 @@ onMounted(async () => { padding: 10px; } +.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-question { + font-weight: bold; + margin-bottom: 10px; +} + +.poll-option { + margin-bottom: 10px; +} + +.poll-option-progress { + position: relative; + background-color: var(--border-color); + height: 20px; + border-radius: 5px; + overflow: hidden; +} + +.poll-option-progress-bar { + background-color: var(--primary-color); + height: 100%; +} + +.poll-option-progress-info { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + line-height: 20px; + color: #fff; +} + +.poll-vote-button { + margin-top: 5px; + color: var(--primary-color); + cursor: pointer; + width: fit-content; +} + +.poll-participants { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.poll-participant-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; +} + .prize-info { display: flex; flex-direction: row; From 23582934fac3a566bb81283422481a6d91dd286e Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:03:17 +0800 Subject: [PATCH 03/23] feat: track poll votes --- .../main/java/com/openisle/dto/PollDto.java | 2 +- .../java/com/openisle/mapper/PostMapper.java | 8 ++++- .../com/openisle/model/PollParticipant.java | 33 +++++++++++++++++++ .../java/com/openisle/model/PollPost.java | 7 ++-- .../com/openisle/service/PostService.java | 11 +++++-- 5 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/com/openisle/model/PollParticipant.java diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index 3612d60ed..107c027e8 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -12,5 +12,5 @@ public class PollDto { private List options; private Map votes; private LocalDateTime endTime; - private List participants; + private Map> participants; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 5577dcdb8..825fa9584 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -6,10 +6,12 @@ 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.PollParticipant; import com.openisle.model.User; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; @@ -19,6 +21,7 @@ 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. */ @@ -102,7 +105,10 @@ public class PostMapper { p.setOptions(pp.getOptions()); p.setVotes(pp.getVotes()); p.setEndTime(pp.getEndTime()); - p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + Map> participants = pp.getParticipants().stream() + .collect(Collectors.groupingBy(PollParticipant::getOptionIndex, + Collectors.mapping(ppart -> userMapper.toAuthorDto(ppart.getUser()), Collectors.toList()))); + p.setParticipants(participants); dto.setPoll(p); } } diff --git a/backend/src/main/java/com/openisle/model/PollParticipant.java b/backend/src/main/java/com/openisle/model/PollParticipant.java new file mode 100644 index 000000000..0fd819e24 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PollParticipant.java @@ -0,0 +1,33 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Represents a single vote in a poll, capturing which user selected which option. + */ +@Entity +@Table(name = "poll_participants") +@Getter +@Setter +@NoArgsConstructor +public class PollParticipant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private PollPost post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "option_index", nullable = false) + private int optionIndex; +} + diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java index c19978c21..9746f4247 100644 --- a/backend/src/main/java/com/openisle/model/PollPost.java +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -30,11 +30,8 @@ public class PollPost extends Post { @Column(name = "vote_count") private Map votes = new HashMap<>(); - @ManyToMany - @JoinTable(name = "poll_participants", - joinColumns = @JoinColumn(name = "post_id"), - inverseJoinColumns = @JoinColumn(name = "user_id")) - private Set participants = new HashSet<>(); + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private Set participants = new HashSet<>(); @Column private LocalDateTime endTime; diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 296133b4c..f4d0d189b 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -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.PollParticipant; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.PollPostRepository; @@ -293,13 +294,19 @@ public class PostService { } User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - if (post.getParticipants().contains(user)) { + boolean alreadyVoted = post.getParticipants().stream() + .anyMatch(p -> p.getUser().equals(user)); + if (alreadyVoted) { throw new IllegalArgumentException("User already voted"); } if (optionIndex < 0 || optionIndex >= post.getOptions().size()) { throw new IllegalArgumentException("Invalid option"); } - post.getParticipants().add(user); + PollParticipant participant = new PollParticipant(); + participant.setPost(post); + participant.setUser(user); + participant.setOptionIndex(optionIndex); + post.getParticipants().add(participant); post.getVotes().merge(optionIndex, 1, Integer::sum); return pollPostRepository.save(post); } From c3ae97f8ba2ce98d62d4bdbf6eb6d72a5fb24912 Mon Sep 17 00:00:00 2001 From: tim Date: Sat, 30 Aug 2025 12:05:35 +0800 Subject: [PATCH 04/23] Revert "feat: track poll votes" This reverts commit 23582934fac3a566bb81283422481a6d91dd286e. --- .../main/java/com/openisle/dto/PollDto.java | 2 +- .../java/com/openisle/mapper/PostMapper.java | 8 +---- .../com/openisle/model/PollParticipant.java | 33 ------------------- .../java/com/openisle/model/PollPost.java | 7 ++-- .../com/openisle/service/PostService.java | 11 ++----- 5 files changed, 9 insertions(+), 52 deletions(-) delete mode 100644 backend/src/main/java/com/openisle/model/PollParticipant.java diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index 107c027e8..3612d60ed 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -12,5 +12,5 @@ public class PollDto { private List options; private Map votes; private LocalDateTime endTime; - private Map> participants; + private List participants; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 825fa9584..5577dcdb8 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -6,12 +6,10 @@ 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.PollParticipant; import com.openisle.model.User; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; @@ -21,7 +19,6 @@ 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. */ @@ -105,10 +102,7 @@ public class PostMapper { p.setOptions(pp.getOptions()); p.setVotes(pp.getVotes()); p.setEndTime(pp.getEndTime()); - Map> participants = pp.getParticipants().stream() - .collect(Collectors.groupingBy(PollParticipant::getOptionIndex, - Collectors.mapping(ppart -> userMapper.toAuthorDto(ppart.getUser()), Collectors.toList()))); - p.setParticipants(participants); + p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); dto.setPoll(p); } } diff --git a/backend/src/main/java/com/openisle/model/PollParticipant.java b/backend/src/main/java/com/openisle/model/PollParticipant.java deleted file mode 100644 index 0fd819e24..000000000 --- a/backend/src/main/java/com/openisle/model/PollParticipant.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.openisle.model; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -/** - * Represents a single vote in a poll, capturing which user selected which option. - */ -@Entity -@Table(name = "poll_participants") -@Getter -@Setter -@NoArgsConstructor -public class PollParticipant { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id", nullable = false) - private PollPost post; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(name = "option_index", nullable = false) - private int optionIndex; -} - diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java index 9746f4247..c19978c21 100644 --- a/backend/src/main/java/com/openisle/model/PollPost.java +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -30,8 +30,11 @@ public class PollPost extends Post { @Column(name = "vote_count") private Map votes = new HashMap<>(); - @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) - private Set participants = new HashSet<>(); + @ManyToMany + @JoinTable(name = "poll_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set participants = new HashSet<>(); @Column private LocalDateTime endTime; diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index f4d0d189b..296133b4c 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -10,7 +10,6 @@ import com.openisle.model.Comment; import com.openisle.model.NotificationType; import com.openisle.model.LotteryPost; import com.openisle.model.PollPost; -import com.openisle.model.PollParticipant; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.PollPostRepository; @@ -294,19 +293,13 @@ public class PostService { } User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); - boolean alreadyVoted = post.getParticipants().stream() - .anyMatch(p -> p.getUser().equals(user)); - if (alreadyVoted) { + if (post.getParticipants().contains(user)) { throw new IllegalArgumentException("User already voted"); } if (optionIndex < 0 || optionIndex >= post.getOptions().size()) { throw new IllegalArgumentException("Invalid option"); } - PollParticipant participant = new PollParticipant(); - participant.setPost(post); - participant.setUser(user); - participant.setOptionIndex(optionIndex); - post.getParticipants().add(participant); + post.getParticipants().add(user); post.getVotes().merge(optionIndex, 1, Integer::sum); return pollPostRepository.save(post); } From 569531b4621fcd5817f0d129afff1bd7bfa5259c Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:06:11 +0800 Subject: [PATCH 05/23] feat: add poll vote repository --- .../main/java/com/openisle/dto/PollDto.java | 1 + .../java/com/openisle/mapper/PostMapper.java | 9 ++++++ .../java/com/openisle/model/PollVote.java | 28 +++++++++++++++++++ .../repository/PollVoteRepository.java | 10 +++++++ .../com/openisle/service/PostService.java | 10 +++++++ 5 files changed, 58 insertions(+) create mode 100644 backend/src/main/java/com/openisle/model/PollVote.java create mode 100644 backend/src/main/java/com/openisle/repository/PollVoteRepository.java diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index 3612d60ed..04e80915e 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -13,4 +13,5 @@ public class PollDto { private Map votes; private LocalDateTime endTime; private List participants; + private Map> optionParticipants; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 5577dcdb8..49d0ccb7d 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -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(); @@ -103,6 +108,10 @@ public class PostMapper { p.setVotes(pp.getVotes()); p.setEndTime(pp.getEndTime()); p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + Map> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream() + .collect(Collectors.groupingBy(PollVote::getOptionIndex, + Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); + p.setOptionParticipants(optionParticipants); dto.setPoll(p); } } diff --git a/backend/src/main/java/com/openisle/model/PollVote.java b/backend/src/main/java/com/openisle/model/PollVote.java new file mode 100644 index 000000000..319ef975a --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PollVote.java @@ -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"})) +@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; +} diff --git a/backend/src/main/java/com/openisle/repository/PollVoteRepository.java b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java new file mode 100644 index 000000000..b2e823e4e --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java @@ -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 { + List findByPostId(Long postId); +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 296133b4c..59d00e4f6 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -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; @@ -301,6 +306,11 @@ public class PostService { } post.getParticipants().add(user); post.getVotes().merge(optionIndex, 1, Integer::sum); + PollVote vote = new PollVote(); + vote.setPost(post); + vote.setUser(user); + vote.setOptionIndex(optionIndex); + pollVoteRepository.save(vote); return pollPostRepository.save(post); } From c6ab431c8739ef4570b7e8dd465b52c38b09b852 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 00:04:35 +0800 Subject: [PATCH 06/23] =?UTF-8?q?fix:=20=E9=A1=B5=E9=9D=A2=E9=80=82?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 140 ++++++++++++++++++----- 1 file changed, 111 insertions(+), 29 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 63be402bd..94062262e 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -172,35 +172,36 @@
-
{{ poll.question }}
-
-
-
{{ opt }}
-
+
+
+
+
-
{{ pollVotes[idx] || 0 }} 票
-
-
- 投票 + v-for="(opt, idx) in poll.options" + :key="idx" + class="poll-option" + @click="voteOption(idx)" + > + + {{ opt }} +
+
+
100
+
投票人
+
-
- +
+
+ 投票 +
+
结果
+ +
+
离结束还有
+
12:00:00
+
@@ -358,6 +359,7 @@ const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) const poll = ref(null) +const showPollResult = ref(false) const countdown = ref('00:00:00') let countdownTimer = null const lotteryParticipants = computed(() => lottery.value?.participants || []) @@ -1239,6 +1241,88 @@ onMounted(async () => { cursor: pointer; } +.poll-option-button { + color: var(--text-color); + padding: 5px 10px; + border-radius: 8px; + background-color: rgb(218, 218, 218); + 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; +} + +.poll-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100px; + border-left: 1px solid var(--normal-border-color); +} + +.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-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: 16px; +} + +.poll-bottom-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.poll-left-time { + display: flex; + flex-direction: row; +} + +.poll-left-time-title { + font-size: 13px; + opacity: 0.7; +} + .action-menu-icon { cursor: pointer; font-size: 18px; @@ -1379,10 +1463,6 @@ onMounted(async () => { margin-bottom: 10px; } -.poll-option { - margin-bottom: 10px; -} - .poll-option-progress { position: relative; background-color: var(--border-color); @@ -1477,12 +1557,14 @@ onMounted(async () => { margin-left: 10px; } +.poll-left-time-title, .prize-end-time-title { font-size: 13px; opacity: 0.7; margin-right: 5px; } +.poll-left-time-value, .prize-end-time-value { font-size: 13px; font-weight: bold; From 1bf92ab1adb4e8d617b5ec2727f10f8fafc91036 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sun, 31 Aug 2025 00:14:12 +0800 Subject: [PATCH 07/23] feat: render poll results with real data --- frontend_nuxt/pages/posts/[id]/index.vue | 46 +++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 94062262e..fbe7aefb4 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -174,7 +174,31 @@
-
+
{{ poll.question }}
+
+
+
{{ opt }}
+
+
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}) +
+
+
+ +
+
+
-
100
+
{{ pollParticipants.length }}
投票人
-
+
投票
-
结果
+
+ 结果 +
离结束还有
-
12:00:00
+
{{ countdown }}
@@ -373,6 +399,7 @@ const hasJoined = computed(() => { return lotteryParticipants.value.some((p) => p.id === Number(authState.userId)) }) const pollParticipants = computed(() => poll.value?.participants || []) +const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {}) const pollVotes = computed(() => poll.value?.votes || {}) const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) const pollPercentages = computed(() => @@ -391,6 +418,9 @@ 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 currentEndTime = computed(() => { if (lottery.value && lottery.value.endTime) return lottery.value.endTime if (poll.value && poll.value.endTime) return poll.value.endTime @@ -908,6 +938,7 @@ const voteOption = async (idx) => { if (res.ok) { toast.success('投票成功') await refreshPost() + showPollResult.value = true } else { toast.error(data.error || '操作失败') } @@ -1293,6 +1324,11 @@ onMounted(async () => { align-items: center; } +.poll-option-result { + margin-bottom: 10px; + margin-right: 10px; +} + .poll-option-input { margin-right: 10px; width: 18px; From ac4f1064e7c5b0e726d39803e27d51e1ef888a92 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 01:05:00 +0800 Subject: [PATCH 08/23] =?UTF-8?q?fix:=20=E7=BB=93=E6=9D=9F=E6=97=B6?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 41 +++++++++++++++--------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index fbe7aefb4..c1f2dd3aa 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -174,18 +174,19 @@
-
{{ poll.question }}
-
+
-
{{ opt }}
+
+
{{ opt }}
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) +
+
-
- {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}) -
-
+
投票
-
+
结果
@@ -1302,7 +1307,6 @@ onMounted(async () => { justify-content: center; align-items: center; min-height: 100px; - border-left: 1px solid var(--normal-border-color); } .total-votes { @@ -1327,6 +1331,9 @@ onMounted(async () => { .poll-option-result { margin-bottom: 10px; margin-right: 10px; + gap: 5px; + display: flex; + flex-direction: column; } .poll-option-input { @@ -1339,7 +1346,7 @@ onMounted(async () => { } .poll-option-text { - font-size: 16px; + font-size: 18px; } .poll-bottom-container { @@ -1501,7 +1508,7 @@ onMounted(async () => { .poll-option-progress { position: relative; - background-color: var(--border-color); + background-color: rgb(187, 187, 187); height: 20px; border-radius: 5px; overflow: hidden; @@ -1512,14 +1519,16 @@ onMounted(async () => { height: 100%; } +.poll-option-info-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + .poll-option-progress-info { - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); font-size: 12px; line-height: 20px; - color: #fff; + color: var(--text-color); } .poll-vote-button { From 6e1a7c773ce3580e70257b1d61524a00f3a7eb05 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 01:19:48 +0800 Subject: [PATCH 09/23] =?UTF-8?q?fix:=20=E6=8A=95=E7=A5=A8=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=87=87=E7=94=A8clientOnly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 120 ++++++++++++----------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index c1f2dd3aa..6a10ffd6c 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -171,71 +171,81 @@
-
-
-
-
-
-
-
{{ opt }}
-
- {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) + +
+
+
+
+
+
+
{{ opt }}
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) +
+
+
+
+
+
+
-
-
-
-
- +
+
+ + {{ opt }}
-
-
- - {{ opt }} -
+
+
{{ pollParticipants.length }}
+
投票人
-
-
{{ pollParticipants.length }}
-
投票人
+
+
+ 投票 +
+
+ 结果 +
+ +
+
离结束还有
+
{{ countdown }}
+
-
-
- 投票 -
-
- 结果 -
- -
-
离结束还有
-
{{ countdown }}
-
-
-
- +
该帖子已关闭,内容仅供阅读,无法进行互动
From db1d7981c55066572ed34163d442a046f952e040 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 01:21:52 +0800 Subject: [PATCH 10/23] =?UTF-8?q?fix:=20checked=E4=BF=AE=E6=94=B9=E4=B8=BA?= =?UTF-8?q?false?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/posts/[id]/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 6a10ffd6c..c6a8f2a2b 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -210,7 +210,7 @@ > From 5a09934866e18e2cb1e43889283c31b9220adf56 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sun, 31 Aug 2025 01:49:37 +0800 Subject: [PATCH 11/23] refactor poll and lottery forms, add poll notifications --- .../openisle/controller/PostController.java | 2 +- .../main/java/com/openisle/dto/PollDto.java | 1 - .../java/com/openisle/dto/PostRequest.java | 1 - .../java/com/openisle/mapper/PostMapper.java | 1 - .../com/openisle/model/NotificationType.java | 6 + .../java/com/openisle/model/PollPost.java | 7 +- .../repository/PollPostRepository.java | 6 + .../com/openisle/service/PostService.java | 40 +- .../controller/PostControllerTest.java | 4 +- .../com/openisle/service/PostServiceTest.java | 2 +- frontend_nuxt/components/LotteryForm.vue | 189 ++++++++++ frontend_nuxt/components/PollForm.vue | 90 +++++ frontend_nuxt/pages/message.vue | 38 ++ frontend_nuxt/pages/new-post.vue | 353 +++--------------- frontend_nuxt/utils/notification.js | 18 + 15 files changed, 442 insertions(+), 316 deletions(-) create mode 100644 frontend_nuxt/components/LotteryForm.vue create mode 100644 frontend_nuxt/components/PollForm.vue diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 90c892834..92981bb3f 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -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()); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index 04e80915e..889b7a5c9 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -8,7 +8,6 @@ import java.util.Map; @Data public class PollDto { - private String question; private List options; private Map votes; private LocalDateTime endTime; diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 7e6619e2a..cd48888fc 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -27,7 +27,6 @@ public class PostRequest { private LocalDateTime startTime; private LocalDateTime endTime; // fields for poll posts - private String question; private List options; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 49d0ccb7d..8c8bd223b 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -103,7 +103,6 @@ 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()); diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index c4b4e0e25..ccfd57eb7 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -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 */ diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java index c19978c21..c9a5363f1 100644 --- a/backend/src/main/java/com/openisle/model/PollPost.java +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -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") @@ -38,4 +34,7 @@ public class PollPost extends Post { @Column private LocalDateTime endTime; + + @Column + private boolean resultAnnounced = false; } diff --git a/backend/src/main/java/com/openisle/repository/PollPostRepository.java b/backend/src/main/java/com/openisle/repository/PollPostRepository.java index d0985fe4b..853009946 100644 --- a/backend/src/main/java/com/openisle/repository/PollPostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PollPostRepository.java @@ -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 { + List findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now); + + List findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now); } diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 59d00e4f6..76873f858 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -135,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() { @@ -177,7 +186,6 @@ public class PostService { Integer pointCost, LocalDateTime startTime, LocalDateTime endTime, - String question, java.util.List options) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); @@ -217,7 +225,6 @@ public class PostService { throw new IllegalArgumentException("At least two options required"); } PollPost pp = new PollPost(); - pp.setQuestion(question); pp.setOptions(options); pp.setEndTime(endTime); post = pp; @@ -269,6 +276,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; } @@ -311,7 +323,29 @@ public class PostService { vote.setUser(user); vote.setOptionIndex(optionIndex); pollVoteRepository.save(vote); - return pollPostRepository.save(post); + 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 diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 90779b57e..f55c2d497 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -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())).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()); } @Test diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index a7bf71caa..f21155f35 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -146,7 +146,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)); } @Test diff --git a/frontend_nuxt/components/LotteryForm.vue b/frontend_nuxt/components/LotteryForm.vue new file mode 100644 index 000000000..30bcdcd8b --- /dev/null +++ b/frontend_nuxt/components/LotteryForm.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend_nuxt/components/PollForm.vue b/frontend_nuxt/components/PollForm.vue new file mode 100644 index 000000000..fe8454505 --- /dev/null +++ b/frontend_nuxt/components/PollForm.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 6da9ce0ef..29dd1b2c8 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -195,6 +195,44 @@ 已开奖 + + + + + diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue new file mode 100644 index 000000000..6672916f6 --- /dev/null +++ b/frontend_nuxt/components/PostPoll.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 97630fee3..db7d9ae86 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -94,157 +94,9 @@
-
-
-
-
-
- - -
-
{{ lottery.prizeDescription }}
-
x {{ lottery.prizeCount }}
-
-
-
离结束还有
-
{{ countdown }}
-
-
-
- 参与抽奖 {{ lottery.pointCost }} -
-
-
-
已参与
-
-
-
-
- -
-
-
- 参与抽奖 {{ lottery.pointCost }} -
-
-
-
已参与
-
-
-
-
- -
- - 获奖者: - -
- {{ lotteryWinners[0].username }} -
-
-
-
+ -
-
-
-
-
-
-
{{ opt }}
-
- {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) -
-
-
-
-
-
- -
-
-
-
-
- - {{ opt }} -
-
-
-
-
{{ pollParticipants.length }}
-
投票人
-
-
-
-
- 投票 -
-
- 结果 -
- -
-
离结束还有
-
{{ countdown }}
-
-
-
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -333,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' @@ -388,7 +242,6 @@ useHead(() => ({ if (import.meta.client) { onBeforeUnmount(() => { window.removeEventListener('scroll', updateCurrentIndex) - if (countdownTimer) clearInterval(countdownTimer) }) } @@ -400,73 +253,6 @@ const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) const poll = ref(null) -const showPollResult = ref(false) -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 pollParticipants = computed(() => poll.value?.participants || []) -const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {}) -const pollVotes = computed(() => poll.value?.votes || {}) -const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) -const pollPercentages = computed(() => - poll.value - ? poll.value.options.map((_, idx) => { - const c = pollVotes.value[idx] || 0 - return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0 - }) - : [], -) -const pollEnded = computed(() => { - if (!poll.value || !poll.value.endTime) return false - return new Date(poll.value.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 currentEndTime = computed(() => { - if (lottery.value && lottery.value.endTime) return lottery.value.endTime - if (poll.value && poll.value.endTime) return poll.value.endTime - return null -}) -const updateCountdown = () => { - if (!currentEndTime.value) { - countdown.value = '00:00:00' - return - } - const diff = new Date(currentEndTime.value).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 articleMenuItems = computed(() => { const items = [] if (isAuthor.value || isAdmin.value) { @@ -628,8 +414,6 @@ watchEffect(() => { postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null poll.value = data.poll || null - if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) - startCountdown() }) // 404 客户端跳转 @@ -920,45 +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 voteOption = async (idx) => { - const token = getToken() - if (!token) { - toast.error('请先登录') - return - } - const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - const data = await res.json().catch(() => ({})) - if (res.ok) { - toast.success('投票成功') - await refreshPost() - showPollResult.value = true - } else { - toast.error(data.error || '操作失败') - } -} - const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -1287,404 +1032,7 @@ onMounted(async () => { cursor: pointer; } -.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; -} - -.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; -} - -.poll-left-time-title { - font-size: 13px; - opacity: 0.7; -} - -.action-menu-icon { - cursor: pointer; - font-size: 18px; - padding: 5px; -} - -.article-info-container { - display: flex; - flex-direction: row; - margin-top: 10px; - gap: 10px; - align-items: center; -} - -.info-content-container { - display: flex; - flex-direction: row; - gap: 10px; - padding: 0px; - border-bottom: 1px solid var(--normal-border-color); -} - -.user-avatar-container { - cursor: pointer; -} - -.user-avatar-item { - width: 50px; - height: 50px; -} - -.user-avatar-item-img { - width: 100%; - height: 100%; - border-radius: 50%; -} - -.info-content { - display: flex; - flex-direction: column; - gap: 3px; - width: 100%; -} - -.info-content-header { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; -} - -.user-name { - font-size: 16px; - font-weight: bold; - opacity: 0.7; -} - -.user-medal { - font-size: 12px; - margin-left: 4px; - opacity: 0.6; - cursor: pointer; - text-decoration: none; - color: var(--text-color); -} - -.post-time { - font-size: 14px; - opacity: 0.5; -} - -.info-content-text { - font-size: 16px; - line-height: 1.5; -} - -.article-footer-container { - display: flex; - flex-direction: row; - gap: 10px; - margin-top: 0px; -} - -.reactions-viewer { - display: flex; - flex-direction: row; - gap: 20px; - align-items: center; -} - -.reactions-viewer-item-container { - display: flex; - flex-direction: row; - gap: 2px; - align-items: center; -} - -.reactions-viewer-item { - font-size: 16px; -} - -.make-reaction-container { - display: flex; - flex-direction: row; - gap: 10px; -} - -.copy-link:hover { - background-color: #e2e2e2; -} - -.comment-editor-wrapper { - 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; -} - -.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-question { - font-weight: bold; - margin-bottom: 10px; -} - -.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); -} - -.poll-vote-button { - margin-top: 5px; - color: var(--primary-color); - cursor: pointer; - width: fit-content; -} - -.poll-participants { - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.poll-participant-avatar { - width: 30px; - height: 30px; - border-radius: 50%; - cursor: pointer; -} - -.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; -} - -.poll-left-time-title, -.prize-end-time-title { - font-size: 13px; - opacity: 0.7; - margin-right: 5px; -} - -.poll-left-time-value, -.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 { @@ -1737,8 +1085,6 @@ onMounted(async () => { width: 100%; } - .join-prize-button, - .join-prize-button-disabled { margin-left: 0; } } From 4baabf2224088860fae013acce2187a302885530 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 11:09:22 +0800 Subject: [PATCH 15/23] Revert "refactor: extract poll and lottery sections" This reverts commit 27efc493b21c43edbbcad2bfea412d0737a19551. --- frontend_nuxt/components/PostLottery.vue | 316 ----------- frontend_nuxt/components/PostPoll.vue | 343 ------------ frontend_nuxt/pages/posts/[id]/index.vue | 662 ++++++++++++++++++++++- 3 files changed, 658 insertions(+), 663 deletions(-) delete mode 100644 frontend_nuxt/components/PostLottery.vue delete mode 100644 frontend_nuxt/components/PostPoll.vue diff --git a/frontend_nuxt/components/PostLottery.vue b/frontend_nuxt/components/PostLottery.vue deleted file mode 100644 index 19e7d6393..000000000 --- a/frontend_nuxt/components/PostLottery.vue +++ /dev/null @@ -1,316 +0,0 @@ - - - - - diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue deleted file mode 100644 index 6672916f6..000000000 --- a/frontend_nuxt/components/PostPoll.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index db7d9ae86..97630fee3 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -94,9 +94,157 @@
- +
+
+
+
+
+ + +
+
{{ lottery.prizeDescription }}
+
x {{ lottery.prizeCount }}
+
+
+
离结束还有
+
{{ countdown }}
+
+
+
+ 参与抽奖 {{ lottery.pointCost }} +
+
+
+
已参与
+
+
+
+
+ +
+
+
+ 参与抽奖 {{ lottery.pointCost }} +
+
+
+
已参与
+
+
+
+
+ +
+ + 获奖者: + +
+ {{ lotteryWinners[0].username }} +
+
+
+
- +
+
+
+
+
+
+
{{ opt }}
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) +
+
+
+
+
+
+ +
+
+
+
+
+ + {{ opt }} +
+
+
+
+
{{ pollParticipants.length }}
+
投票人
+
+
+
+
+ 投票 +
+
+ 结果 +
+ +
+
离结束还有
+
{{ countdown }}
+
+
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -185,8 +333,6 @@ 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' @@ -242,6 +388,7 @@ useHead(() => ({ if (import.meta.client) { onBeforeUnmount(() => { window.removeEventListener('scroll', updateCurrentIndex) + if (countdownTimer) clearInterval(countdownTimer) }) } @@ -253,6 +400,73 @@ const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) const poll = ref(null) +const showPollResult = ref(false) +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 pollParticipants = computed(() => poll.value?.participants || []) +const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {}) +const pollVotes = computed(() => poll.value?.votes || {}) +const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) +const pollPercentages = computed(() => + poll.value + ? poll.value.options.map((_, idx) => { + const c = pollVotes.value[idx] || 0 + return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0 + }) + : [], +) +const pollEnded = computed(() => { + if (!poll.value || !poll.value.endTime) return false + return new Date(poll.value.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 currentEndTime = computed(() => { + if (lottery.value && lottery.value.endTime) return lottery.value.endTime + if (poll.value && poll.value.endTime) return poll.value.endTime + return null +}) +const updateCountdown = () => { + if (!currentEndTime.value) { + countdown.value = '00:00:00' + return + } + const diff = new Date(currentEndTime.value).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 articleMenuItems = computed(() => { const items = [] if (isAuthor.value || isAdmin.value) { @@ -414,6 +628,8 @@ watchEffect(() => { postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null poll.value = data.poll || null + if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) + startCountdown() }) // 404 客户端跳转 @@ -704,6 +920,45 @@ 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 voteOption = async (idx) => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json().catch(() => ({})) + if (res.ok) { + toast.success('投票成功') + await refreshPost() + showPollResult.value = true + } else { + toast.error(data.error || '操作失败') + } +} + const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -1032,7 +1287,404 @@ onMounted(async () => { cursor: pointer; } +.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; +} + +.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; +} + +.poll-left-time-title { + font-size: 13px; + opacity: 0.7; +} + +.action-menu-icon { + cursor: pointer; + font-size: 18px; + padding: 5px; +} + +.article-info-container { + display: flex; + flex-direction: row; + margin-top: 10px; + gap: 10px; + align-items: center; +} + +.info-content-container { + display: flex; + flex-direction: row; + gap: 10px; + padding: 0px; + border-bottom: 1px solid var(--normal-border-color); +} + +.user-avatar-container { + cursor: pointer; +} + +.user-avatar-item { + width: 50px; + height: 50px; +} + +.user-avatar-item-img { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.info-content { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; +} + +.info-content-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.user-name { + font-size: 16px; + font-weight: bold; + opacity: 0.7; +} + +.user-medal { + font-size: 12px; + margin-left: 4px; + opacity: 0.6; + cursor: pointer; + text-decoration: none; + color: var(--text-color); +} + +.post-time { + font-size: 14px; + opacity: 0.5; +} + +.info-content-text { + font-size: 16px; + line-height: 1.5; +} + +.article-footer-container { + display: flex; + flex-direction: row; + gap: 10px; + margin-top: 0px; +} + +.reactions-viewer { + display: flex; + flex-direction: row; + gap: 20px; + align-items: center; +} + +.reactions-viewer-item-container { + display: flex; + flex-direction: row; + gap: 2px; + align-items: center; +} + +.reactions-viewer-item { + font-size: 16px; +} + +.make-reaction-container { + display: flex; + flex-direction: row; + gap: 10px; +} + +.copy-link:hover { + background-color: #e2e2e2; +} + +.comment-editor-wrapper { + 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; +} + +.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-question { + font-weight: bold; + margin-bottom: 10px; +} + +.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); +} + +.poll-vote-button { + margin-top: 5px; + color: var(--primary-color); + cursor: pointer; + width: fit-content; +} + +.poll-participants { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.poll-participant-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; +} + +.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; +} + +.poll-left-time-title, +.prize-end-time-title { + font-size: 13px; + opacity: 0.7; + margin-right: 5px; +} + +.poll-left-time-value, +.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 { @@ -1085,6 +1737,8 @@ onMounted(async () => { width: 100%; } + .join-prize-button, + .join-prize-button-disabled { margin-left: 0; } } From 39c34a9048adeabbea9469624f386dcd5874508a Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sun, 31 Aug 2025 11:10:20 +0800 Subject: [PATCH 16/23] feat: add PostPoll and PostLottery components --- frontend_nuxt/components/PostLottery.vue | 310 +++++++++++++ frontend_nuxt/components/PostPoll.vue | 318 +++++++++++++ frontend_nuxt/pages/posts/[id]/index.vue | 552 +---------------------- 3 files changed, 632 insertions(+), 548 deletions(-) create mode 100644 frontend_nuxt/components/PostLottery.vue create mode 100644 frontend_nuxt/components/PostPoll.vue diff --git a/frontend_nuxt/components/PostLottery.vue b/frontend_nuxt/components/PostLottery.vue new file mode 100644 index 000000000..29d487685 --- /dev/null +++ b/frontend_nuxt/components/PostLottery.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue new file mode 100644 index 000000000..a6e7832ac --- /dev/null +++ b/frontend_nuxt/components/PostPoll.vue @@ -0,0 +1,318 @@ + + + + + diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 97630fee3..b518fef73 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -94,157 +94,9 @@
-
-
-
-
-
- - -
-
{{ lottery.prizeDescription }}
-
x {{ lottery.prizeCount }}
-
-
-
离结束还有
-
{{ countdown }}
-
-
-
- 参与抽奖 {{ lottery.pointCost }} -
-
-
-
已参与
-
-
-
-
- -
-
-
- 参与抽奖 {{ lottery.pointCost }} -
-
-
-
已参与
-
-
-
-
- -
- - 获奖者: - -
- {{ lotteryWinners[0].username }} -
-
-
-
+ -
-
-
-
-
-
-
{{ opt }}
-
- {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) -
-
-
-
-
-
- -
-
-
-
-
- - {{ opt }} -
-
-
-
-
{{ pollParticipants.length }}
-
投票人
-
-
-
-
- 投票 -
-
- 结果 -
- -
-
离结束还有
-
{{ countdown }}
-
-
-
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -333,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' @@ -388,7 +242,6 @@ useHead(() => ({ if (import.meta.client) { onBeforeUnmount(() => { window.removeEventListener('scroll', updateCurrentIndex) - if (countdownTimer) clearInterval(countdownTimer) }) } @@ -400,73 +253,6 @@ const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) const poll = ref(null) -const showPollResult = ref(false) -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 pollParticipants = computed(() => poll.value?.participants || []) -const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {}) -const pollVotes = computed(() => poll.value?.votes || {}) -const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) -const pollPercentages = computed(() => - poll.value - ? poll.value.options.map((_, idx) => { - const c = pollVotes.value[idx] || 0 - return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0 - }) - : [], -) -const pollEnded = computed(() => { - if (!poll.value || !poll.value.endTime) return false - return new Date(poll.value.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 currentEndTime = computed(() => { - if (lottery.value && lottery.value.endTime) return lottery.value.endTime - if (poll.value && poll.value.endTime) return poll.value.endTime - return null -}) -const updateCountdown = () => { - if (!currentEndTime.value) { - countdown.value = '00:00:00' - return - } - const diff = new Date(currentEndTime.value).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 articleMenuItems = computed(() => { const items = [] if (isAuthor.value || isAdmin.value) { @@ -628,8 +414,6 @@ watchEffect(() => { postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null poll.value = data.poll || null - if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) - startCountdown() }) // 404 客户端跳转 @@ -920,45 +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 voteOption = async (idx) => { - const token = getToken() - if (!token) { - toast.error('请先登录') - return - } - const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - }) - const data = await res.json().catch(() => ({})) - if (res.ok) { - toast.success('投票成功') - await refreshPost() - showPollResult.value = true - } else { - toast.error(data.error || '操作失败') - } -} - const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -1287,95 +1032,6 @@ onMounted(async () => { cursor: pointer; } -.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; -} - -.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; -} - -.poll-left-time-title { - font-size: 13px; - opacity: 0.7; -} - .action-menu-icon { cursor: pointer; font-size: 18px; @@ -1491,201 +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; -} - -.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-question { - font-weight: bold; - margin-bottom: 10px; -} - -.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); -} - -.poll-vote-button { - margin-top: 5px; - color: var(--primary-color); - cursor: pointer; - width: fit-content; -} - -.poll-participants { - display: flex; - flex-wrap: wrap; - gap: 5px; -} - -.poll-participant-avatar { - width: 30px; - height: 30px; - border-radius: 50%; - cursor: pointer; -} - -.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; -} - -.poll-left-time-title, -.prize-end-time-title { - font-size: 13px; - opacity: 0.7; - margin-right: 5px; -} - -.poll-left-time-value, -.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); @@ -1736,10 +1197,5 @@ onMounted(async () => { .loading-container { width: 100%; } - - .join-prize-button, - .join-prize-button-disabled { - margin-left: 0; - } } From 18db4d73172abdd78469540ec939e25b30694798 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 11:14:48 +0800 Subject: [PATCH 17/23] =?UTF-8?q?fix:=20toolbar=20=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/assets/global.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index 0cd7713de..0435f54e3 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -93,7 +93,7 @@ body { .vditor-toolbar--pin { top: calc(var(--header-height) + 1px) !important; - z-index: 2000; + z-index: 20; } .vditor-panel { From 3808becc8bc8d804bba3eec9f72b36476ded97f1 Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 31 Aug 2025 11:25:34 +0800 Subject: [PATCH 18/23] =?UTF-8?q?fix:=20=E5=A4=9A=E9=80=89ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/components/PostPoll.vue | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue index a6e7832ac..2f021907e 100644 --- a/frontend_nuxt/components/PostPoll.vue +++ b/frontend_nuxt/components/PostPoll.vue @@ -38,6 +38,14 @@ {{ opt }}
+ +
+
+ + 该投票为多选 +
+
加入投票
+
@@ -204,6 +212,7 @@ const voteOption = async (idx) => { flex-direction: column; overflow-y: auto; flex: 4; + border-right: 1px solid var(--normal-border-color); } .poll-info { @@ -265,6 +274,9 @@ const voteOption = async (idx) => { .poll-left-time { display: flex; flex-direction: row; + align-items: center; + justify-content: center; + gap: 5px; } .poll-left-time-title { @@ -303,6 +315,34 @@ const voteOption = async (idx) => { color: var(--text-color); } +.multi-selection-container { + padding: 20px 15px 20px 5px; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.multi-selection-title { + font-size: 13px; + color: var(--text-color); +} + +.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; From 2f339fdbdb5cefe2f67d19f8b292c890a5cd4ac8 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Sun, 31 Aug 2025 12:13:41 +0800 Subject: [PATCH 19/23] feat: enable multi-option polls --- .../openisle/controller/PostController.java | 4 +- .../main/java/com/openisle/dto/PollDto.java | 1 + .../java/com/openisle/dto/PostRequest.java | 1 + .../java/com/openisle/mapper/PostMapper.java | 1 + .../java/com/openisle/model/PollPost.java | 3 + .../java/com/openisle/model/PollVote.java | 2 +- .../com/openisle/service/PostService.java | 30 ++++--- .../controller/PostControllerTest.java | 4 +- .../com/openisle/service/PostServiceTest.java | 2 +- frontend_nuxt/components/PollForm.vue | 13 +++ frontend_nuxt/components/PostPoll.vue | 85 +++++++++++++++---- frontend_nuxt/pages/new-post.vue | 3 + 12 files changed, 117 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 92981bb3f..598bc7ed2 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -44,7 +44,7 @@ public class PostController { req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), req.getPrizeCount(), req.getPointCost(), req.getStartTime(), req.getEndTime(), - 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 vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) { + public ResponseEntity vote(@PathVariable Long id, @RequestParam("option") List option, Authentication auth) { postService.votePoll(id, auth.getName(), option); return ResponseEntity.ok().build(); } diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java index 889b7a5c9..af8da1de0 100644 --- a/backend/src/main/java/com/openisle/dto/PollDto.java +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -13,4 +13,5 @@ public class PollDto { private LocalDateTime endTime; private List participants; private Map> optionParticipants; + private boolean multiple; } diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index cd48888fc..bdebadb6c 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -28,5 +28,6 @@ public class PostRequest { private LocalDateTime endTime; // fields for poll posts private List options; + private Boolean multiple; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 8c8bd223b..57277357d 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -111,6 +111,7 @@ public class PostMapper { .collect(Collectors.groupingBy(PollVote::getOptionIndex, Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); p.setOptionParticipants(optionParticipants); + p.setMultiple(pp.isMultiple()); dto.setPoll(p); } } diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java index c9a5363f1..503b6bfbf 100644 --- a/backend/src/main/java/com/openisle/model/PollPost.java +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -32,6 +32,9 @@ public class PollPost extends Post { inverseJoinColumns = @JoinColumn(name = "user_id")) private Set participants = new HashSet<>(); + @Column + private boolean multiple = false; + @Column private LocalDateTime endTime; diff --git a/backend/src/main/java/com/openisle/model/PollVote.java b/backend/src/main/java/com/openisle/model/PollVote.java index 319ef975a..41994dc90 100644 --- a/backend/src/main/java/com/openisle/model/PollVote.java +++ b/backend/src/main/java/com/openisle/model/PollVote.java @@ -6,7 +6,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; @Entity -@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id"})) +@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"})) @Getter @Setter @NoArgsConstructor diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 76873f858..3b088817c 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -186,7 +186,8 @@ public class PostService { Integer pointCost, LocalDateTime startTime, LocalDateTime endTime, - java.util.List options) { + java.util.List options, + Boolean multiple) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { @@ -227,6 +228,7 @@ public class PostService { PollPost pp = new PollPost(); pp.setOptions(options); pp.setEndTime(endTime); + pp.setMultiple(multiple != null && multiple); post = pp; } else { post = new Post(); @@ -302,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 optionIndices) { PollPost post = pollPostRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) { @@ -313,16 +315,24 @@ 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 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); - PollVote vote = new PollVote(); - vote.setPost(post); - vote.setUser(user); - vote.setOptionIndex(optionIndex); - pollVoteRepository.save(vote); + 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); diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index f55c2d497..5b667e926 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -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(), 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(), any()); } @Test diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index f21155f35..de3f58f43 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -146,7 +146,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, null)); } @Test diff --git a/frontend_nuxt/components/PollForm.vue b/frontend_nuxt/components/PollForm.vue index fe8454505..15d0adf95 100644 --- a/frontend_nuxt/components/PollForm.vue +++ b/frontend_nuxt/components/PollForm.vue @@ -18,6 +18,12 @@
+
+ +
@@ -80,6 +86,13 @@ const removeOption = (idx) => { display: flex; flex-direction: column; } +.poll-multiple-row { + display: flex; + align-items: center; +} +.multiple-checkbox { + margin-right: 5px; +} .time-picker { max-width: 200px; height: 30px; diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue index 2f021907e..d038957c4 100644 --- a/frontend_nuxt/components/PostPoll.vue +++ b/frontend_nuxt/components/PostPoll.vue @@ -29,23 +29,42 @@
-
- - {{ opt }} -
- -
-
- - 该投票为多选 + +
@@ -178,6 +197,40 @@ const voteOption = async (idx) => { toast.error(data.error || '操作失败') } } + +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 || '操作失败') + } +}