mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
feat: enable multi-option polls
This commit is contained in:
@@ -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<Void> vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) {
|
||||
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
|
||||
postService.votePoll(id, auth.getName(), option);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ public class PollDto {
|
||||
private LocalDateTime endTime;
|
||||
private List<AuthorDto> participants;
|
||||
private Map<Integer, List<AuthorDto>> optionParticipants;
|
||||
private boolean multiple;
|
||||
}
|
||||
|
||||
@@ -28,5 +28,6 @@ public class PostRequest {
|
||||
private LocalDateTime endTime;
|
||||
// fields for poll posts
|
||||
private List<String> options;
|
||||
private Boolean multiple;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ public class PollPost extends Post {
|
||||
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
||||
private Set<User> participants = new HashSet<>();
|
||||
|
||||
@Column
|
||||
private boolean multiple = false;
|
||||
|
||||
@Column
|
||||
private LocalDateTime endTime;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -186,7 +186,8 @@ public class PostService {
|
||||
Integer pointCost,
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime,
|
||||
java.util.List<String> options) {
|
||||
java.util.List<String> options,
|
||||
Boolean multiple) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
java.time.LocalDateTime.now().minusMinutes(5));
|
||||
if (recent >= 1) {
|
||||
@@ -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<Integer> optionIndices) {
|
||||
PollPost post = pollPostRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
|
||||
@@ -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<Integer> unique = new java.util.HashSet<>(optionIndices);
|
||||
for (int optionIndex : unique) {
|
||||
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
|
||||
throw new IllegalArgumentException("Invalid option");
|
||||
}
|
||||
}
|
||||
post.getParticipants().add(user);
|
||||
post.getVotes().merge(optionIndex, 1, Integer::sum);
|
||||
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||
</client-only>
|
||||
</div>
|
||||
<div class="poll-multiple-row">
|
||||
<label class="poll-row-title">
|
||||
<input type="checkbox" v-model="data.multiple" class="multiple-checkbox" />
|
||||
多选
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,23 +29,42 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
:key="idx"
|
||||
class="poll-option"
|
||||
@click="voteOption(idx)"
|
||||
>
|
||||
<input type="radio" :checked="false" name="poll-option" class="poll-option-input" />
|
||||
<span class="poll-option-text">{{ opt }}</span>
|
||||
</div>
|
||||
|
||||
<div class="multi-selection-container">
|
||||
<div class="multi-selection-title">
|
||||
<i class="fas fa-info-circle info-icon"></i>
|
||||
该投票为多选
|
||||
<template v-if="poll.multiple">
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
:key="idx"
|
||||
class="poll-option"
|
||||
@click="toggleOption(idx)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedOptions.includes(idx)"
|
||||
class="poll-option-input"
|
||||
/>
|
||||
<span class="poll-option-text">{{ opt }}</span>
|
||||
</div>
|
||||
<div class="join-poll-button"><i class="fas fa-plus"></i> 加入投票</div>
|
||||
</div>
|
||||
|
||||
<div class="multi-selection-container">
|
||||
<div class="multi-selection-title">
|
||||
<i class="fas fa-info-circle info-icon"></i>
|
||||
该投票为多选
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitMultiPoll">
|
||||
<i class="fas fa-plus"></i> 加入投票
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
:key="idx"
|
||||
class="poll-option"
|
||||
@click="voteOption(idx)"
|
||||
>
|
||||
<input type="radio" :checked="false" name="poll-option" class="poll-option-input" />
|
||||
<span class="poll-option-text">{{ opt }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="poll-info">
|
||||
@@ -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 || '操作失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -74,6 +74,7 @@ const lottery = reactive({
|
||||
const poll = reactive({
|
||||
options: ['', ''],
|
||||
endTime: null,
|
||||
multiple: false,
|
||||
})
|
||||
const startTime = ref(null)
|
||||
const isWaitingPosting = ref(false)
|
||||
@@ -121,6 +122,7 @@ const clearPost = async () => {
|
||||
startTime.value = null
|
||||
poll.options = ['', '']
|
||||
poll.endTime = null
|
||||
poll.multiple = false
|
||||
|
||||
// 删除草稿
|
||||
const token = getToken()
|
||||
@@ -318,6 +320,7 @@ const submitPost = async () => {
|
||||
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
||||
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
||||
options: postType.value === 'POLL' ? poll.options : undefined,
|
||||
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
|
||||
startTime:
|
||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
||||
|
||||
Reference in New Issue
Block a user