mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-18 13:01:02 +08:00
Compare commits
8 Commits
codex/add-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf92ab1ad | ||
|
|
c6ab431c87 | ||
|
|
aaa25d5c2f | ||
|
|
569531b462 | ||
|
|
c3ae97f8ba | ||
|
|
a57f3e6406 | ||
|
|
23582934fa | ||
|
|
5adee4db0e |
@@ -13,4 +13,5 @@ public class PollDto {
|
||||
private Map<Integer, Integer> votes;
|
||||
private LocalDateTime endTime;
|
||||
private List<AuthorDto> participants;
|
||||
private Map<Integer, List<AuthorDto>> optionParticipants;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
|
||||
28
backend/src/main/java/com/openisle/model/PollVote.java
Normal file
28
backend/src/main/java/com/openisle/model/PollVote.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,35 +172,62 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="poll" class="post-poll-container">
|
||||
<div class="poll-question">{{ poll.question }}</div>
|
||||
<div class="poll-options">
|
||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option">
|
||||
<div class="poll-option-text">{{ opt }}</div>
|
||||
<div class="poll-option-progress">
|
||||
<div
|
||||
class="poll-option-progress-bar"
|
||||
:style="{ width: pollPercentages[idx] + '%' }"
|
||||
></div>
|
||||
<div class="poll-option-progress-info">{{ pollVotes[idx] || 0 }} 票</div>
|
||||
<div class="poll-top-container">
|
||||
<div class="poll-options-container">
|
||||
<div class="poll-question">{{ poll.question }}</div>
|
||||
<div v-if="showPollResult">
|
||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||
<div class="poll-option-text">{{ opt }}</div>
|
||||
<div class="poll-option-progress">
|
||||
<div
|
||||
class="poll-option-progress-bar"
|
||||
: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
|
||||
v-if="loggedIn && !hasVoted && !pollEnded"
|
||||
class="poll-vote-button"
|
||||
@click="voteOption(idx)"
|
||||
>
|
||||
投票
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
: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 class="poll-info">
|
||||
<div class="total-votes">{{ pollParticipants.length }}</div>
|
||||
<div class="total-votes-title">投票人</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pollParticipants.length" class="poll-participants">
|
||||
<BaseImage
|
||||
v-for="p in pollParticipants"
|
||||
:key="p.id"
|
||||
class="poll-participant-avatar"
|
||||
:src="p.avatar"
|
||||
alt="avatar"
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
<div class="poll-bottom-container">
|
||||
<div v-if="showPollResult" class="poll-option-button" @click="showPollResult = false">
|
||||
<i class="fas fa-chevron-left"></i> 投票
|
||||
</div>
|
||||
<div v-else class="poll-option-button" @click="showPollResult = true">
|
||||
<i class="fas fa-chart-bar"></i> 结果
|
||||
</div>
|
||||
|
||||
<div class="poll-left-time">
|
||||
<div class="poll-left-time-title">离结束还有</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -358,6 +385,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 || [])
|
||||
@@ -371,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(() =>
|
||||
@@ -389,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
|
||||
@@ -906,6 +938,7 @@ const voteOption = async (idx) => {
|
||||
if (res.ok) {
|
||||
toast.success('投票成功')
|
||||
await refreshPost()
|
||||
showPollResult.value = true
|
||||
} else {
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
@@ -1239,6 +1272,93 @@ 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-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 {
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
@@ -1379,10 +1499,6 @@ onMounted(async () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.poll-option {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.poll-option-progress {
|
||||
position: relative;
|
||||
background-color: var(--border-color);
|
||||
@@ -1477,12 +1593,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;
|
||||
|
||||
Reference in New Issue
Block a user