Compare commits

...

8 Commits

Author SHA1 Message Date
Tim
1bf92ab1ad feat: render poll results with real data 2025-08-31 00:14:12 +08:00
tim
c6ab431c87 fix: 页面适配 2025-08-31 00:04:35 +08:00
Tim
aaa25d5c2f Merge pull request #794 from nagisa77/codex/add-participant-info-to-vote-response-y233h3
feat: return poll option participants
2025-08-30 12:07:01 +08:00
Tim
569531b462 feat: add poll vote repository 2025-08-30 12:06:11 +08:00
tim
c3ae97f8ba Revert "feat: track poll votes"
This reverts commit 23582934fa.
2025-08-30 12:05:35 +08:00
Tim
a57f3e6406 Merge pull request #793 from nagisa77/codex/add-participant-info-to-vote-response
feat: expose poll option participants
2025-08-30 12:03:34 +08:00
Tim
23582934fa feat: track poll votes 2025-08-30 12:03:17 +08:00
Tim
5adee4db0e Merge pull request #792 from nagisa77/codex/add-voting-feature-to-post
feat: add poll post support
2025-08-29 23:56:41 +08:00
6 changed files with 205 additions and 29 deletions

View File

@@ -13,4 +13,5 @@ public class PollDto {
private Map<Integer, Integer> votes; private Map<Integer, Integer> votes;
private LocalDateTime endTime; private LocalDateTime endTime;
private List<AuthorDto> participants; private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
} }

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import com.openisle.model.Comment;
import com.openisle.model.NotificationType; import com.openisle.model.NotificationType;
import com.openisle.model.LotteryPost; import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost; import com.openisle.model.PollPost;
import com.openisle.model.PollVote;
import com.openisle.repository.PostRepository; import com.openisle.repository.PostRepository;
import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.LotteryPostRepository;
import com.openisle.repository.PollPostRepository; import com.openisle.repository.PollPostRepository;
@@ -22,6 +23,7 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.ReactionRepository; import com.openisle.repository.ReactionRepository;
import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.NotificationRepository; import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PollVoteRepository;
import com.openisle.model.Role; import com.openisle.model.Role;
import com.openisle.exception.RateLimitException; import com.openisle.exception.RateLimitException;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -57,6 +59,7 @@ public class PostService {
private final TagRepository tagRepository; private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository; private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository; private final PollPostRepository pollPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode; private PublishMode publishMode;
private final NotificationService notificationService; private final NotificationService notificationService;
private final SubscriptionService subscriptionService; private final SubscriptionService subscriptionService;
@@ -82,6 +85,7 @@ public class PostService {
TagRepository tagRepository, TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository, LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository, PollPostRepository pollPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService, NotificationService notificationService,
SubscriptionService subscriptionService, SubscriptionService subscriptionService,
CommentService commentService, CommentService commentService,
@@ -102,6 +106,7 @@ public class PostService {
this.tagRepository = tagRepository; this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository; this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository; this.pollPostRepository = pollPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService; this.notificationService = notificationService;
this.subscriptionService = subscriptionService; this.subscriptionService = subscriptionService;
this.commentService = commentService; this.commentService = commentService;
@@ -301,6 +306,11 @@ public class PostService {
} }
post.getParticipants().add(user); post.getParticipants().add(user);
post.getVotes().merge(optionIndex, 1, Integer::sum); 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); return pollPostRepository.save(post);
} }

View File

@@ -172,35 +172,62 @@
</div> </div>
</div> </div>
<div v-if="poll" class="post-poll-container"> <div v-if="poll" class="post-poll-container">
<div class="poll-question">{{ poll.question }}</div> <div class="poll-top-container">
<div class="poll-options"> <div class="poll-options-container">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option"> <div class="poll-question">{{ poll.question }}</div>
<div class="poll-option-text">{{ opt }}</div> <div v-if="showPollResult">
<div class="poll-option-progress"> <div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div <div class="poll-option-text">{{ opt }}</div>
class="poll-option-progress-bar" <div class="poll-option-progress">
:style="{ width: pollPercentages[idx] + '%' }" <div
></div> class="poll-option-progress-bar"
<div class="poll-option-progress-info">{{ pollVotes[idx] || 0 }} </div> :style="{ width: pollPercentages[idx] + '%' }"
></div>
<div class="poll-option-progress-info">
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }})
</div>
</div>
<div class="poll-participants">
<BaseImage
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
</div> </div>
<div <div v-else>
v-if="loggedIn && !hasVoted && !pollEnded" <div
class="poll-vote-button" v-for="(opt, idx) in poll.options"
@click="voteOption(idx)" :key="idx"
> class="poll-option"
投票 @click="voteOption(idx)"
>
<input type="radio" :checked="true" name="poll-option" class="poll-option-input" />
<span class="poll-option-text">{{ opt }}</span>
</div>
</div> </div>
</div> </div>
<div class="poll-info">
<div class="total-votes">{{ pollParticipants.length }}</div>
<div class="total-votes-title">投票人</div>
</div>
</div> </div>
<div v-if="pollParticipants.length" class="poll-participants"> <div class="poll-bottom-container">
<BaseImage <div v-if="showPollResult" class="poll-option-button" @click="showPollResult = false">
v-for="p in pollParticipants" <i class="fas fa-chevron-left"></i> 投票
:key="p.id" </div>
class="poll-participant-avatar" <div v-else class="poll-option-button" @click="showPollResult = true">
:src="p.avatar" <i class="fas fa-chart-bar"></i> 结果
alt="avatar" </div>
@click="gotoUser(p.id)"
/> <div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div> </div>
</div> </div>
@@ -358,6 +385,7 @@ const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username) const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null) const lottery = ref(null)
const poll = ref(null) const poll = ref(null)
const showPollResult = ref(false)
const countdown = ref('00:00:00') const countdown = ref('00:00:00')
let countdownTimer = null let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || []) const lotteryParticipants = computed(() => lottery.value?.participants || [])
@@ -371,6 +399,7 @@ const hasJoined = computed(() => {
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId)) return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
}) })
const pollParticipants = computed(() => poll.value?.participants || []) const pollParticipants = computed(() => poll.value?.participants || [])
const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {})
const pollVotes = computed(() => poll.value?.votes || {}) const pollVotes = computed(() => poll.value?.votes || {})
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
const pollPercentages = computed(() => const pollPercentages = computed(() =>
@@ -389,6 +418,9 @@ const hasVoted = computed(() => {
if (!loggedIn.value) return false if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId)) return pollParticipants.value.some((p) => p.id === Number(authState.userId))
}) })
watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const currentEndTime = computed(() => { const currentEndTime = computed(() => {
if (lottery.value && lottery.value.endTime) return lottery.value.endTime if (lottery.value && lottery.value.endTime) return lottery.value.endTime
if (poll.value && poll.value.endTime) return poll.value.endTime if (poll.value && poll.value.endTime) return poll.value.endTime
@@ -906,6 +938,7 @@ const voteOption = async (idx) => {
if (res.ok) { if (res.ok) {
toast.success('投票成功') toast.success('投票成功')
await refreshPost() await refreshPost()
showPollResult.value = true
} else { } else {
toast.error(data.error || '操作失败') toast.error(data.error || '操作失败')
} }
@@ -1239,6 +1272,93 @@ onMounted(async () => {
cursor: pointer; 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-result {
margin-bottom: 10px;
margin-right: 10px;
}
.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 { .action-menu-icon {
cursor: pointer; cursor: pointer;
font-size: 18px; font-size: 18px;
@@ -1379,10 +1499,6 @@ onMounted(async () => {
margin-bottom: 10px; margin-bottom: 10px;
} }
.poll-option {
margin-bottom: 10px;
}
.poll-option-progress { .poll-option-progress {
position: relative; position: relative;
background-color: var(--border-color); background-color: var(--border-color);
@@ -1477,12 +1593,14 @@ onMounted(async () => {
margin-left: 10px; margin-left: 10px;
} }
.poll-left-time-title,
.prize-end-time-title { .prize-end-time-title {
font-size: 13px; font-size: 13px;
opacity: 0.7; opacity: 0.7;
margin-right: 5px; margin-right: 5px;
} }
.poll-left-time-value,
.prize-end-time-value { .prize-end-time-value {
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: bold;