mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 13:30:55 +08:00
Compare commits
5 Commits
codex/add-
...
codex/modi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a09934866 | ||
|
|
db1d7981c5 | ||
|
|
6e1a7c773c | ||
|
|
ac4f1064e7 | ||
|
|
4e98fd6a89 |
@@ -44,7 +44,7 @@ public class PostController {
|
|||||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||||
req.getPrizeCount(), req.getPointCost(),
|
req.getPrizeCount(), req.getPointCost(),
|
||||||
req.getStartTime(), req.getEndTime(),
|
req.getStartTime(), req.getEndTime(),
|
||||||
req.getQuestion(), req.getOptions());
|
req.getOptions());
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import java.util.Map;
|
|||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PollDto {
|
public class PollDto {
|
||||||
private String question;
|
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
private Map<Integer, Integer> votes;
|
private Map<Integer, Integer> votes;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ public class PostRequest {
|
|||||||
private LocalDateTime startTime;
|
private LocalDateTime startTime;
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private String question;
|
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ public class PostMapper {
|
|||||||
|
|
||||||
if (post instanceof PollPost pp) {
|
if (post instanceof PollPost pp) {
|
||||||
PollDto p = new PollDto();
|
PollDto p = new PollDto();
|
||||||
p.setQuestion(pp.getQuestion());
|
|
||||||
p.setOptions(pp.getOptions());
|
p.setOptions(pp.getOptions());
|
||||||
p.setVotes(pp.getVotes());
|
p.setVotes(pp.getVotes());
|
||||||
p.setEndTime(pp.getEndTime());
|
p.setEndTime(pp.getEndTime());
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ public enum NotificationType {
|
|||||||
LOTTERY_WIN,
|
LOTTERY_WIN,
|
||||||
/** Your lottery post was drawn */
|
/** Your lottery post was drawn */
|
||||||
LOTTERY_DRAW,
|
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 */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ import java.util.*;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@PrimaryKeyJoinColumn(name = "post_id")
|
@PrimaryKeyJoinColumn(name = "post_id")
|
||||||
public class PollPost extends Post {
|
public class PollPost extends Post {
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private String question;
|
|
||||||
|
|
||||||
@ElementCollection
|
@ElementCollection
|
||||||
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
|
@CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id"))
|
||||||
@Column(name = "option_text")
|
@Column(name = "option_text")
|
||||||
@@ -38,4 +34,7 @@ public class PollPost extends Post {
|
|||||||
|
|
||||||
@Column
|
@Column
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private boolean resultAnnounced = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,11 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.PollPost;
|
import com.openisle.model.PollPost;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
|
public interface PollPostRepository extends JpaRepository<PollPost, Long> {
|
||||||
|
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
|
||||||
|
|
||||||
|
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ public class PostService {
|
|||||||
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
|
for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) {
|
||||||
applicationContext.getBean(PostService.class).finalizeLottery(lp.getId());
|
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() {
|
public PublishMode getPublishMode() {
|
||||||
@@ -177,7 +186,6 @@ public class PostService {
|
|||||||
Integer pointCost,
|
Integer pointCost,
|
||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
String question,
|
|
||||||
java.util.List<String> options) {
|
java.util.List<String> options) {
|
||||||
long recent = postRepository.countByAuthorAfter(username,
|
long recent = postRepository.countByAuthorAfter(username,
|
||||||
java.time.LocalDateTime.now().minusMinutes(5));
|
java.time.LocalDateTime.now().minusMinutes(5));
|
||||||
@@ -217,7 +225,6 @@ public class PostService {
|
|||||||
throw new IllegalArgumentException("At least two options required");
|
throw new IllegalArgumentException("At least two options required");
|
||||||
}
|
}
|
||||||
PollPost pp = new PollPost();
|
PollPost pp = new PollPost();
|
||||||
pp.setQuestion(question);
|
|
||||||
pp.setOptions(options);
|
pp.setOptions(options);
|
||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
post = pp;
|
post = pp;
|
||||||
@@ -269,6 +276,11 @@ public class PostService {
|
|||||||
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
|
() -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()),
|
||||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
|
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()));
|
||||||
scheduledFinalizations.put(lp.getId(), future);
|
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;
|
return post;
|
||||||
}
|
}
|
||||||
@@ -311,7 +323,29 @@ public class PostService {
|
|||||||
vote.setUser(user);
|
vote.setUser(user);
|
||||||
vote.setOptionIndex(optionIndex);
|
vote.setOptionIndex(optionIndex);
|
||||||
pollVoteRepository.save(vote);
|
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
|
@Transactional
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
|||||||
post.setTags(Set.of(tag));
|
post.setTags(Set.of(tag));
|
||||||
|
|
||||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
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(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
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
|
@Test
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ class PostServiceTest {
|
|||||||
|
|
||||||
assertThrows(RateLimitException.class,
|
assertThrows(RateLimitException.class,
|
||||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
() -> 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
|
@Test
|
||||||
|
|||||||
189
frontend_nuxt/components/LotteryForm.vue
Normal file
189
frontend_nuxt/components/LotteryForm.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lottery-section">
|
||||||
|
<AvatarCropper
|
||||||
|
:src="data.tempPrizeIcon"
|
||||||
|
:show="data.showPrizeCropper"
|
||||||
|
@close="data.showPrizeCropper = false"
|
||||||
|
@crop="onPrizeCropped"
|
||||||
|
/>
|
||||||
|
<div class="prize-row">
|
||||||
|
<span class="prize-row-title">奖品图片</span>
|
||||||
|
<label class="prize-container">
|
||||||
|
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
|
||||||
|
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
||||||
|
<div class="prize-overlay">上传奖品图片</div>
|
||||||
|
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="prize-name-row">
|
||||||
|
<span class="prize-row-title">奖品描述</span>
|
||||||
|
<BaseInput v-model="data.prizeDescription" placeholder="奖品描述" />
|
||||||
|
</div>
|
||||||
|
<div class="prize-count-row">
|
||||||
|
<span class="prize-row-title">奖品数量</span>
|
||||||
|
<div class="prize-count-input">
|
||||||
|
<input
|
||||||
|
class="prize-count-input-field"
|
||||||
|
type="number"
|
||||||
|
v-model.number="data.prizeCount"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prize-point-row">
|
||||||
|
<span class="prize-row-title">参与所需积分</span>
|
||||||
|
<div class="prize-count-input">
|
||||||
|
<input
|
||||||
|
class="prize-count-input-field"
|
||||||
|
type="number"
|
||||||
|
v-model.number="data.pointCost"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prize-time-row">
|
||||||
|
<span class="prize-row-title">抽奖结束时间</span>
|
||||||
|
<client-only>
|
||||||
|
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||||
|
</client-only>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
|
import BaseImage from '~/components/BaseImage.vue'
|
||||||
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import { watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||||
|
|
||||||
|
const onPrizeIconChange = (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
props.data.tempPrizeIcon = reader.result
|
||||||
|
props.data.showPrizeCropper = true
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPrizeCropped = ({ file, url }) => {
|
||||||
|
props.data.prizeIconFile = file
|
||||||
|
props.data.prizeIcon = url
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data.prizeCount,
|
||||||
|
(val) => {
|
||||||
|
if (!val || val < 1) props.data.prizeCount = 1
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data.pointCost,
|
||||||
|
(val) => {
|
||||||
|
if (val === undefined || val === null || val < 0) props.data.pointCost = 0
|
||||||
|
if (val > 100) props.data.pointCost = 100
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lottery-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 200px;
|
||||||
|
}
|
||||||
|
.prize-row-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.prize-row,
|
||||||
|
.prize-name-row,
|
||||||
|
.prize-count-row,
|
||||||
|
.prize-point-row,
|
||||||
|
.prize-time-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.prize-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.default-prize-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
opacity: 0.1;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.prize-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.prize-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.prize-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.prize-container:hover .prize-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.prize-count-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.prize-count-input-field {
|
||||||
|
width: 50px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
|
}
|
||||||
|
.time-picker {
|
||||||
|
max-width: 200px;
|
||||||
|
height: 30px;
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
frontend_nuxt/components/PollForm.vue
Normal file
90
frontend_nuxt/components/PollForm.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<div class="poll-section">
|
||||||
|
<div class="poll-options-row">
|
||||||
|
<span class="poll-row-title">投票选项</span>
|
||||||
|
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||||
|
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||||
|
<i
|
||||||
|
v-if="data.options.length > 2"
|
||||||
|
class="fa-solid fa-xmark remove-option-icon"
|
||||||
|
@click="removeOption(idx)"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div class="add-option" @click="addOption">添加选项</div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-time-row">
|
||||||
|
<span class="poll-row-title">投票结束时间</span>
|
||||||
|
<client-only>
|
||||||
|
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||||
|
</client-only>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||||
|
|
||||||
|
const addOption = () => {
|
||||||
|
props.data.options.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeOption = (idx) => {
|
||||||
|
if (props.data.options.length > 2) {
|
||||||
|
props.data.options.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.poll-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 200px;
|
||||||
|
}
|
||||||
|
.poll-row-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.poll-option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.remove-option-icon {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.add-option {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.poll-options-row,
|
||||||
|
.poll-time-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.time-picker {
|
||||||
|
max-width: 200px;
|
||||||
|
height: 30px;
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -195,6 +195,44 @@
|
|||||||
已开奖
|
已开奖
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POLL_VOTE'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
有用户参与了你的投票贴
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POLL_RESULT_OWNER'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你的投票帖
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
已出结果
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'POLL_RESULT_PARTICIPANT'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你参与的投票帖
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
已出结果
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
|
|||||||
@@ -35,95 +35,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="postType === 'LOTTERY'" class="lottery-section">
|
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||||
<AvatarCropper
|
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||||
:src="tempPrizeIcon"
|
|
||||||
:show="showPrizeCropper"
|
|
||||||
@close="showPrizeCropper = false"
|
|
||||||
@crop="onPrizeCropped"
|
|
||||||
/>
|
|
||||||
<div class="prize-row">
|
|
||||||
<span class="prize-row-title">奖品图片</span>
|
|
||||||
<label class="prize-container">
|
|
||||||
<BaseImage v-if="prizeIcon" :src="prizeIcon" class="prize-preview" alt="prize" />
|
|
||||||
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
|
||||||
<div class="prize-overlay">上传奖品图片</div>
|
|
||||||
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="prize-name-row">
|
|
||||||
<span class="prize-row-title">奖品描述</span>
|
|
||||||
<BaseInput v-model="prizeDescription" placeholder="奖品描述" />
|
|
||||||
</div>
|
|
||||||
<div class="prize-count-row">
|
|
||||||
<span class="prize-row-title">奖品数量</span>
|
|
||||||
<div class="prize-count-input">
|
|
||||||
<input
|
|
||||||
class="prize-count-input-field"
|
|
||||||
type="number"
|
|
||||||
v-model.number="prizeCount"
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="prize-point-row">
|
|
||||||
<span class="prize-row-title">参与所需积分</span>
|
|
||||||
<div class="prize-count-input">
|
|
||||||
<input
|
|
||||||
class="prize-count-input-field"
|
|
||||||
type="number"
|
|
||||||
v-model.number="pointCost"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="prize-time-row">
|
|
||||||
<span class="prize-row-title">抽奖结束时间</span>
|
|
||||||
<client-only>
|
|
||||||
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
|
|
||||||
</client-only>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="postType === 'POLL'" class="poll-section">
|
|
||||||
<div class="poll-question-row">
|
|
||||||
<span class="poll-row-title">投票问题</span>
|
|
||||||
<BaseInput v-model="pollQuestion" placeholder="请输入投票问题" />
|
|
||||||
</div>
|
|
||||||
<div class="poll-options-row">
|
|
||||||
<span class="poll-row-title">投票选项</span>
|
|
||||||
<div class="poll-option-item" v-for="(opt, idx) in pollOptions" :key="idx">
|
|
||||||
<BaseInput v-model="pollOptions[idx]" placeholder="选项内容" />
|
|
||||||
<i
|
|
||||||
v-if="pollOptions.length > 2"
|
|
||||||
class="fa-solid fa-xmark remove-option-icon"
|
|
||||||
@click="removeOption(idx)"
|
|
||||||
></i>
|
|
||||||
</div>
|
|
||||||
<div class="add-option" @click="addOption">添加选项</div>
|
|
||||||
</div>
|
|
||||||
<div class="poll-time-row">
|
|
||||||
<span class="poll-row-title">投票结束时间</span>
|
|
||||||
<client-only>
|
|
||||||
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
|
|
||||||
</client-only>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import { computed, onMounted, ref, reactive } from 'vue'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
|
||||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
import PostEditor from '~/components/PostEditor.vue'
|
import PostEditor from '~/components/PostEditor.vue'
|
||||||
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
|
import LotteryForm from '~/components/LotteryForm.vue'
|
||||||
|
import PollForm from '~/components/PollForm.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -134,57 +60,26 @@ const content = ref('')
|
|||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const postType = ref('NORMAL')
|
const postType = ref('NORMAL')
|
||||||
const prizeIcon = ref('')
|
const lottery = reactive({
|
||||||
const prizeIconFile = ref(null)
|
prizeIcon: '',
|
||||||
const tempPrizeIcon = ref('')
|
prizeIconFile: null,
|
||||||
const showPrizeCropper = ref(false)
|
tempPrizeIcon: '',
|
||||||
const prizeName = ref('')
|
showPrizeCropper: false,
|
||||||
const prizeCount = ref(1)
|
prizeName: '',
|
||||||
const prizeDescription = ref('')
|
prizeDescription: '',
|
||||||
const pointCost = ref(0)
|
prizeCount: 1,
|
||||||
const endTime = ref(null)
|
pointCost: 0,
|
||||||
|
endTime: null,
|
||||||
|
})
|
||||||
|
const poll = reactive({
|
||||||
|
options: ['', ''],
|
||||||
|
endTime: null,
|
||||||
|
})
|
||||||
const startTime = 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 isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
|
|
||||||
const onPrizeIconChange = (e) => {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (file) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => {
|
|
||||||
tempPrizeIcon.value = reader.result
|
|
||||||
showPrizeCropper.value = true
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPrizeCropped = ({ file, url }) => {
|
|
||||||
prizeIconFile.value = file
|
|
||||||
prizeIcon.value = url
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(prizeCount, (val) => {
|
|
||||||
if (!val || val < 1) prizeCount.value = 1
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(pointCost, (val) => {
|
|
||||||
if (val === undefined || val === null || val < 0) pointCost.value = 0
|
|
||||||
if (val > 100) pointCost.value = 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const addOption = () => {
|
|
||||||
pollOptions.value.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeOption = (idx) => {
|
|
||||||
if (pollOptions.value.length > 2) pollOptions.value.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDraft = async () => {
|
const loadDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -214,17 +109,18 @@ const clearPost = async () => {
|
|||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
prizeIcon.value = ''
|
lottery.prizeIcon = ''
|
||||||
prizeIconFile.value = null
|
lottery.prizeIconFile = null
|
||||||
tempPrizeIcon.value = ''
|
lottery.tempPrizeIcon = ''
|
||||||
showPrizeCropper.value = false
|
lottery.showPrizeCropper = false
|
||||||
prizeDescription.value = ''
|
lottery.prizeName = ''
|
||||||
prizeCount.value = 1
|
lottery.prizeDescription = ''
|
||||||
pointCost.value = 0
|
lottery.prizeCount = 1
|
||||||
endTime.value = null
|
lottery.pointCost = 0
|
||||||
|
lottery.endTime = null
|
||||||
startTime.value = null
|
startTime.value = null
|
||||||
pollQuestion.value = ''
|
poll.options = ['', '']
|
||||||
pollOptions.value = ['', '']
|
poll.endTime = null
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -354,37 +250,33 @@ const submitPost = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (postType.value === 'LOTTERY') {
|
if (postType.value === 'LOTTERY') {
|
||||||
if (!prizeIcon.value) {
|
if (!lottery.prizeIcon) {
|
||||||
toast.error('请上传奖品图片')
|
toast.error('请上传奖品图片')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!prizeCount.value || prizeCount.value < 1) {
|
if (!lottery.prizeCount || lottery.prizeCount < 1) {
|
||||||
toast.error('奖品数量必须大于0')
|
toast.error('奖品数量必须大于0')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!prizeDescription.value) {
|
if (!lottery.prizeDescription) {
|
||||||
toast.error('请输入奖品描述')
|
toast.error('请输入奖品描述')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!endTime.value) {
|
if (!lottery.endTime) {
|
||||||
toast.error('请选择抽奖结束时间')
|
toast.error('请选择抽奖结束时间')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pointCost.value < 0 || pointCost.value > 100) {
|
if (lottery.pointCost < 0 || lottery.pointCost > 100) {
|
||||||
toast.error('参与积分需在0到100之间')
|
toast.error('参与积分需在0到100之间')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (postType.value === 'POLL') {
|
if (postType.value === 'POLL') {
|
||||||
if (!pollQuestion.value.trim()) {
|
if (poll.options.length < 2 || poll.options.some((o) => !o.trim())) {
|
||||||
toast.error('请输入投票问题')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (pollOptions.value.length < 2 || pollOptions.value.some((o) => !o.trim())) {
|
|
||||||
toast.error('请填写至少两个投票选项')
|
toast.error('请填写至少两个投票选项')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!endTime.value) {
|
if (!poll.endTime) {
|
||||||
toast.error('请选择投票结束时间')
|
toast.error('请选择投票结束时间')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -393,10 +285,10 @@ const submitPost = async () => {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
isWaitingPosting.value = true
|
isWaitingPosting.value = true
|
||||||
let prizeIconUrl = prizeIcon.value
|
let prizeIconUrl = lottery.prizeIcon
|
||||||
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
if (postType.value === 'LOTTERY' && lottery.prizeIconFile) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', prizeIconFile.value)
|
form.append('file', lottery.prizeIconFile)
|
||||||
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -422,19 +314,20 @@ const submitPost = async () => {
|
|||||||
tagIds: selectedTags.value,
|
tagIds: selectedTags.value,
|
||||||
type: postType.value,
|
type: postType.value,
|
||||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
||||||
prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined,
|
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
|
||||||
prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined,
|
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
||||||
question: postType.value === 'POLL' ? pollQuestion.value : undefined,
|
options: postType.value === 'POLL' ? poll.options : undefined,
|
||||||
options: postType.value === 'POLL' ? pollOptions.value : undefined,
|
|
||||||
startTime:
|
startTime:
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined,
|
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||||
endTime:
|
endTime:
|
||||||
postType.value === 'LOTTERY' || postType.value === 'POLL'
|
postType.value === 'LOTTERY'
|
||||||
? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
: undefined,
|
: postType.value === 'POLL'
|
||||||
|
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -569,150 +462,6 @@ const submitPost = async () => {
|
|||||||
padding-bottom: 50px;
|
padding-bottom: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lottery-section,
|
|
||||||
.poll-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
|
|
||||||
margin-bottom: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-row-title,
|
|
||||||
.poll-row-title {
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-name-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var(--lottery-background-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.default-prize-icon {
|
|
||||||
font-size: 30px;
|
|
||||||
opacity: 0.1;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-container:hover .prize-overlay {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-count-row,
|
|
||||||
.prize-time-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-count-input {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prize-name-input {
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 0 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color);
|
|
||||||
background-color: var(--lottery-background-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-picker {
|
|
||||||
max-width: 200px;
|
|
||||||
height: 30px;
|
|
||||||
background-color: var(--lottery-background-color);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 0 10px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.new-post-page {
|
.new-post-page {
|
||||||
width: calc(100vw - 20px);
|
width: calc(100vw - 20px);
|
||||||
|
|||||||
@@ -171,66 +171,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="poll" class="post-poll-container">
|
<ClientOnly>
|
||||||
<div class="poll-top-container">
|
<div v-if="poll" class="post-poll-container">
|
||||||
<div class="poll-options-container">
|
<div class="poll-top-container">
|
||||||
<div class="poll-question">{{ poll.question }}</div>
|
<div class="poll-options-container">
|
||||||
<div v-if="showPollResult">
|
<div v-if="showPollResult || pollEnded || hasVoted">
|
||||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
<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-info-container">
|
||||||
<div class="poll-option-progress">
|
<div class="poll-option-text">{{ opt }}</div>
|
||||||
<div
|
<div class="poll-option-progress-info">
|
||||||
class="poll-option-progress-bar"
|
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
|
||||||
:style="{ width: pollPercentages[idx] + '%' }"
|
</div>
|
||||||
></div>
|
</div>
|
||||||
<div class="poll-option-progress-info">
|
<div class="poll-option-progress">
|
||||||
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }})
|
<div
|
||||||
|
class="poll-option-progress-bar"
|
||||||
|
:style="{ width: pollPercentages[idx] + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-participants">
|
||||||
|
<BaseImage
|
||||||
|
v-for="p in pollOptionParticipants[idx] || []"
|
||||||
|
:key="p.id"
|
||||||
|
class="poll-participant-avatar"
|
||||||
|
:src="p.avatar"
|
||||||
|
alt="avatar"
|
||||||
|
@click="gotoUser(p.id)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poll-participants">
|
</div>
|
||||||
<BaseImage
|
<div v-else>
|
||||||
v-for="p in pollOptionParticipants[idx] || []"
|
<div
|
||||||
:key="p.id"
|
v-for="(opt, idx) in poll.options"
|
||||||
class="poll-participant-avatar"
|
:key="idx"
|
||||||
:src="p.avatar"
|
class="poll-option"
|
||||||
alt="avatar"
|
@click="voteOption(idx)"
|
||||||
@click="gotoUser(p.id)"
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:checked="false"
|
||||||
|
name="poll-option"
|
||||||
|
class="poll-option-input"
|
||||||
/>
|
/>
|
||||||
|
<span class="poll-option-text">{{ opt }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div class="poll-info">
|
||||||
<div
|
<div class="total-votes">{{ pollParticipants.length }}</div>
|
||||||
v-for="(opt, idx) in poll.options"
|
<div class="total-votes-title">投票人</div>
|
||||||
: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="poll-bottom-container">
|
||||||
<div class="total-votes">{{ pollParticipants.length }}</div>
|
<div
|
||||||
<div class="total-votes-title">投票人</div>
|
v-if="showPollResult && !pollEnded && !hasVoted"
|
||||||
|
class="poll-option-button"
|
||||||
|
@click="showPollResult = false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left"></i> 投票
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!pollEnded && !hasVoted"
|
||||||
|
class="poll-option-button"
|
||||||
|
@click="showPollResult = true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chart-bar"></i> 结果
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="poll-left-time">
|
||||||
|
<div class="poll-left-time-title">离结束还有</div>
|
||||||
|
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="poll-bottom-container">
|
</ClientOnly>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="closed" class="post-close-container">该帖子已关闭,内容仅供阅读,无法进行互动</div>
|
<div v-if="closed" class="post-close-container">该帖子已关闭,内容仅供阅读,无法进行互动</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
@@ -1302,7 +1317,6 @@ onMounted(async () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
border-left: 1px solid var(--normal-border-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.total-votes {
|
.total-votes {
|
||||||
@@ -1327,6 +1341,9 @@ onMounted(async () => {
|
|||||||
.poll-option-result {
|
.poll-option-result {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
gap: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-option-input {
|
.poll-option-input {
|
||||||
@@ -1339,7 +1356,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-option-text {
|
.poll-option-text {
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-bottom-container {
|
.poll-bottom-container {
|
||||||
@@ -1501,7 +1518,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.poll-option-progress {
|
.poll-option-progress {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: var(--border-color);
|
background-color: rgb(187, 187, 187);
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1512,14 +1529,16 @@ onMounted(async () => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-option-info-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-option-progress-info {
|
.poll-option-progress-info {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #fff;
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-vote-button {
|
.poll-vote-button {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ const iconMap = {
|
|||||||
POINT_REDEEM: 'fas fa-gift',
|
POINT_REDEEM: 'fas fa-gift',
|
||||||
LOTTERY_WIN: 'fas fa-trophy',
|
LOTTERY_WIN: 'fas fa-trophy',
|
||||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||||
|
POLL_VOTE: 'fas fa-square-poll-vertical',
|
||||||
|
POLL_RESULT_OWNER: 'fas fa-flag-checkered',
|
||||||
|
POLL_RESULT_PARTICIPANT: 'fas fa-flag-checkered',
|
||||||
MENTION: 'fas fa-at',
|
MENTION: 'fas fa-at',
|
||||||
POST_DELETED: 'fas fa-trash',
|
POST_DELETED: 'fas fa-trash',
|
||||||
POST_FEATURED: 'fas fa-star',
|
POST_FEATURED: 'fas fa-star',
|
||||||
@@ -210,6 +213,21 @@ function createFetchNotifications() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (
|
||||||
|
n.type === 'POLL_VOTE' ||
|
||||||
|
n.type === 'POLL_RESULT_OWNER' ||
|
||||||
|
n.type === 'POLL_RESULT_PARTICIPANT'
|
||||||
|
) {
|
||||||
|
arr.push({
|
||||||
|
...n,
|
||||||
|
icon: iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markNotificationRead(n.id)
|
||||||
|
navigateTo(`/posts/${n.post.id}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
|
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
Reference in New Issue
Block a user