diff --git a/backend/src/main/java/com/openisle/config/SchedulerConfig.java b/backend/src/main/java/com/openisle/config/SchedulerConfig.java new file mode 100644 index 000000000..c087ccd6b --- /dev/null +++ b/backend/src/main/java/com/openisle/config/SchedulerConfig.java @@ -0,0 +1,20 @@ +package com.openisle.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.TaskScheduler; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + scheduler.setThreadNamePrefix("lottery-"); + scheduler.initialize(); + return scheduler; + } +} diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 52932b53a..736cba857 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -39,7 +39,9 @@ public class PostController { return ResponseEntity.badRequest().build(); } Post post = postService.createPost(auth.getName(), req.getCategoryId(), - req.getTitle(), req.getContent(), req.getTagIds()); + req.getTitle(), req.getContent(), req.getTagIds(), + req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), + req.getPrizeCount(), req.getStartTime(), req.getEndTime()); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); @@ -67,6 +69,12 @@ public class PostController { return ResponseEntity.ok(postMapper.toDetailDto(post, viewer)); } + @PostMapping("/{id}/lottery/join") + public ResponseEntity joinLottery(@PathVariable Long id, Authentication auth) { + postService.joinLottery(id, auth.getName()); + 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/LotteryDto.java b/backend/src/main/java/com/openisle/dto/LotteryDto.java new file mode 100644 index 000000000..db6728993 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/LotteryDto.java @@ -0,0 +1,17 @@ +package com.openisle.dto; + +import lombok.Data; +import java.time.LocalDateTime; +import java.util.List; + +/** Metadata for lottery posts. */ +@Data +public class LotteryDto { + private String prizeDescription; + private String prizeIcon; + private int prizeCount; + private LocalDateTime startTime; + private LocalDateTime endTime; + private List participants; + private List winners; +} diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 0c40537e1..014d68bf7 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -2,8 +2,11 @@ package com.openisle.dto; import lombok.Data; +import java.time.LocalDateTime; import java.util.List; +import com.openisle.model.PostType; + /** * Request body for creating or updating a post. */ @@ -14,5 +17,13 @@ public class PostRequest { private String content; private List tagIds; private String captcha; + + // optional for lottery posts + private PostType type; + private String prizeDescription; + private String prizeIcon; + private Integer prizeCount; + private LocalDateTime startTime; + private LocalDateTime endTime; } diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index f77e1df59..d849c225b 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -1,6 +1,7 @@ package com.openisle.dto; import com.openisle.model.PostStatus; +import com.openisle.model.PostType; import lombok.Data; import java.time.LocalDateTime; @@ -28,5 +29,7 @@ public class PostSummaryDto { private boolean subscribed; private int reward; private int pointReward; + private PostType type; + private LotteryDto lottery; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index 12eba57fb..58652f17b 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -4,8 +4,10 @@ import com.openisle.dto.CommentDto; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostSummaryDto; import com.openisle.dto.ReactionDto; +import com.openisle.dto.LotteryDto; import com.openisle.model.CommentSort; import com.openisle.model.Post; +import com.openisle.model.LotteryPost; import com.openisle.model.User; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; @@ -75,5 +77,18 @@ public class PostMapper { dto.setLastReplyAt(last != null ? last : post.getCreatedAt()); dto.setReward(0); dto.setSubscribed(false); + dto.setType(post.getType()); + + if (post instanceof LotteryPost lp) { + LotteryDto l = new LotteryDto(); + l.setPrizeDescription(lp.getPrizeDescription()); + l.setPrizeIcon(lp.getPrizeIcon()); + l.setPrizeCount(lp.getPrizeCount()); + l.setStartTime(lp.getStartTime()); + l.setEndTime(lp.getEndTime()); + l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + dto.setLottery(l); + } } } diff --git a/backend/src/main/java/com/openisle/model/LotteryPost.java b/backend/src/main/java/com/openisle/model/LotteryPost.java new file mode 100644 index 000000000..79639a1da --- /dev/null +++ b/backend/src/main/java/com/openisle/model/LotteryPost.java @@ -0,0 +1,46 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "lottery_posts") +@Getter +@Setter +@NoArgsConstructor +@PrimaryKeyJoinColumn(name = "post_id") +public class LotteryPost extends Post { + + @Column + private String prizeDescription; + + @Column + private String prizeIcon; + + @Column(nullable = false) + private int prizeCount; + + @Column + private LocalDateTime startTime; + + @Column + private LocalDateTime endTime; + + @ManyToMany + @JoinTable(name = "lottery_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set participants = new HashSet<>(); + + @ManyToMany + @JoinTable(name = "lottery_winners", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set winners = new HashSet<>(); +} diff --git a/backend/src/main/java/com/openisle/model/Post.java b/backend/src/main/java/com/openisle/model/Post.java index 0f7a752e1..0f5a709f1 100644 --- a/backend/src/main/java/com/openisle/model/Post.java +++ b/backend/src/main/java/com/openisle/model/Post.java @@ -9,11 +9,11 @@ import org.hibernate.annotations.CreationTimestamp; import java.util.HashSet; import java.util.Set; +import java.time.LocalDateTime; + import com.openisle.model.Tag; -import java.time.LocalDateTime; - /** * Post entity representing an article posted by a user. */ @@ -22,6 +22,7 @@ import java.time.LocalDateTime; @Setter @NoArgsConstructor @Table(name = "posts") +@Inheritance(strategy = InheritanceType.JOINED) public class Post { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -59,6 +60,10 @@ public class Post { @Column(nullable = false) private PostStatus status = PostStatus.PUBLISHED; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostType type = PostType.NORMAL; + @Column private LocalDateTime pinnedAt; diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java new file mode 100644 index 000000000..d14a28701 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -0,0 +1,6 @@ +package com.openisle.model; + +public enum PostType { + NORMAL, + LOTTERY +} diff --git a/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java new file mode 100644 index 000000000..f06350329 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/LotteryPostRepository.java @@ -0,0 +1,7 @@ +package com.openisle.repository; + +import com.openisle.model.LotteryPost; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LotteryPostRepository 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 c3293c4c9..a694e06c4 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -2,12 +2,15 @@ package com.openisle.service; import com.openisle.model.Post; import com.openisle.model.PostStatus; +import com.openisle.model.PostType; import com.openisle.model.PublishMode; import com.openisle.model.User; import com.openisle.model.Category; import com.openisle.model.Comment; import com.openisle.model.NotificationType; +import com.openisle.model.LotteryPost; import com.openisle.repository.PostRepository; +import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; @@ -21,6 +24,8 @@ import com.openisle.model.Role; import com.openisle.exception.RateLimitException; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.scheduling.TaskScheduler; +import com.openisle.service.EmailSender; import java.util.List; import org.springframework.data.domain.PageRequest; @@ -28,12 +33,19 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; +import java.time.ZoneId; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; + @Service public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; + private final LotteryPostRepository lotteryPostRepository; private PublishMode publishMode; private final NotificationService notificationService; private final SubscriptionService subscriptionService; @@ -44,12 +56,15 @@ public class PostService { private final NotificationRepository notificationRepository; private final PostReadService postReadService; private final ImageUploader imageUploader; + private final TaskScheduler taskScheduler; + private final EmailSender emailSender; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, UserRepository userRepository, CategoryRepository categoryRepository, TagRepository tagRepository, + LotteryPostRepository lotteryPostRepository, NotificationService notificationService, SubscriptionService subscriptionService, CommentService commentService, @@ -59,11 +74,14 @@ public class PostService { NotificationRepository notificationRepository, PostReadService postReadService, ImageUploader imageUploader, + TaskScheduler taskScheduler, + EmailSender emailSender, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; + this.lotteryPostRepository = lotteryPostRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; this.commentService = commentService; @@ -73,6 +91,8 @@ public class PostService { this.notificationRepository = notificationRepository; this.postReadService = postReadService; this.imageUploader = imageUploader; + this.taskScheduler = taskScheduler; + this.emailSender = emailSender; this.publishMode = publishMode; } @@ -88,7 +108,13 @@ public class PostService { Long categoryId, String title, String content, - java.util.List tagIds) { + java.util.List tagIds, + PostType type, + String prizeDescription, + String prizeIcon, + Integer prizeCount, + LocalDateTime startTime, + LocalDateTime endTime) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { @@ -108,14 +134,31 @@ public class PostService { if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } - Post post = new Post(); + PostType actualType = type != null ? type : PostType.NORMAL; + Post post; + if (actualType == PostType.LOTTERY) { + LotteryPost lp = new LotteryPost(); + lp.setPrizeDescription(prizeDescription); + lp.setPrizeIcon(prizeIcon); + lp.setPrizeCount(prizeCount != null ? prizeCount : 0); + lp.setStartTime(startTime); + lp.setEndTime(endTime); + post = lp; + } else { + post = new Post(); + } + post.setType(actualType); post.setTitle(title); post.setContent(content); post.setAuthor(author); post.setCategory(category); - post.setTags(new java.util.HashSet<>(tags)); + post.setTags(new HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); - post = postRepository.save(post); + if (post instanceof LotteryPost) { + post = lotteryPostRepository.save((LotteryPost) post); + } else { + post = postRepository.save(post); + } imageUploader.addReferences(imageUploader.extractUrls(content)); if (post.getStatus() == PostStatus.PENDING) { java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); @@ -141,9 +184,42 @@ public class PostService { } } notificationService.notifyMentions(content, author, post, null); + + if (post instanceof LotteryPost lp && lp.getEndTime() != null) { + taskScheduler.schedule(() -> finalizeLottery(lp.getId()), + java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + } return post; } + public void joinLottery(Long postId, String username) { + LotteryPost post = lotteryPostRepository.findById(postId) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); + post.getParticipants().add(user); + lotteryPostRepository.save(post); + } + + private void finalizeLottery(Long postId) { + lotteryPostRepository.findById(postId).ifPresent(lp -> { + List participants = new ArrayList<>(lp.getParticipants()); + if (participants.isEmpty()) { + return; + } + Collections.shuffle(participants); + int winnersCount = Math.min(lp.getPrizeCount(), participants.size()); + java.util.Set winners = new java.util.HashSet<>(participants.subList(0, winnersCount)); + lp.setWinners(winners); + lotteryPostRepository.save(lp); + for (User w : winners) { + if (w.getEmail() != null) { + emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); + } + } + }); + } + @Transactional public Post viewPost(Long id, String viewer) { Post post = postRepository.findById(id) diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue new file mode 100644 index 000000000..2f2791349 --- /dev/null +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -0,0 +1,46 @@ + + + + + + diff --git a/frontend_nuxt/main.js b/frontend_nuxt/main.js index 8bf35d832..82933ef5e 100644 --- a/frontend_nuxt/main.js +++ b/frontend_nuxt/main.js @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://www.open-isle.com' -// export const API_BASE_URL = 'http://127.0.0.1:8081' +// export const API_BASE_URL = 'https://www.open-isle.com' +export const API_BASE_URL = 'http://127.0.0.1:8081' // export const API_BASE_URL = 'http://30.211.97.238:8081' export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com' export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ' diff --git a/frontend_nuxt/package-lock.json b/frontend_nuxt/package-lock.json index 0262f98eb..9395676f6 100644 --- a/frontend_nuxt/package-lock.json +++ b/frontend_nuxt/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "cropperjs": "^1.6.2", "echarts": "^5.6.0", + "flatpickr": "^4.6.13", "highlight.js": "^11.11.1", "ldrs": "^1.0.0", "markdown-it": "^14.1.0", @@ -16,6 +17,7 @@ "vditor": "^3.11.1", "vue-easy-lightbox": "^1.19.0", "vue-echarts": "^7.0.3", + "vue-flatpickr-component": "^12.0.0", "vue-toastification": "^2.0.0-rc.5" } }, @@ -4868,6 +4870,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flatpickr": { + "version": "4.6.13", + "resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz", + "integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==", + "license": "MIT" + }, "node_modules/fn.name": { "version": "1.1.0", "license": "MIT" @@ -10227,6 +10235,21 @@ } } }, + "node_modules/vue-flatpickr-component": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/vue-flatpickr-component/-/vue-flatpickr-component-12.0.0.tgz", + "integrity": "sha512-CJ5jrgTaeD66Z4mjEocSTAdB/n6IGSlUICwdBanpyCI8hswq5rwXvEYQ5IKA3K3uVjP5pBlY9Rg6o3xoszTPpA==", + "license": "MIT", + "dependencies": { + "flatpickr": "^4.6.13" + }, + "engines": { + "node": ">=14.13.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, "node_modules/vue-router": { "version": "4.5.1", "license": "MIT", diff --git a/frontend_nuxt/package.json b/frontend_nuxt/package.json index ee734e167..2b6d110e9 100644 --- a/frontend_nuxt/package.json +++ b/frontend_nuxt/package.json @@ -19,6 +19,8 @@ "vditor": "^3.11.1", "vue-easy-lightbox": "^1.19.0", "vue-echarts": "^7.0.3", - "vue-toastification": "^2.0.0-rc.5" + "vue-toastification": "^2.0.0-rc.5", + "flatpickr": "^4.6.13", + "vue-flatpickr-component": "^12.0.0" } } diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 93f9b4211..3319f0d0e 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -10,6 +10,7 @@
+
@@ -32,31 +33,100 @@
发布中...
+
+ +
+ +
+
+ 奖品描述 + +
+
+ 奖品数量 +
+ +
+
+
+ 抽奖结束时间 + + + +
+
@@ -366,6 +487,98 @@ export default { padding-bottom: 50px; } +.lottery-section { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.prize-row { + display: flex; +} + +.prize-container { + position: relative; + width: 100px; + height: 100px; + border-radius: 10px; + overflow: hidden; + cursor: pointer; +} + +.default-prize-icon { + font-size: 100px; + opacity: 0.5; + 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; + align-items: center; + gap: 10px; +} + +.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); +} + +.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); +} + +.time-picker { + max-width: 200px; + height: 30px; +} + @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 174ddf450..dec958de9 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -77,6 +77,39 @@ +
+
+
+
+
+ prize + +
+
{{ lottery.prizeDescription }}
+
x {{ lottery.prizeCount }}
+
+
+
离结束还有
+
{{ countdown }}
+
+
参与抽奖
+
+
+
已参与
+
+
+
+
+
+ avatar +
+ + 获奖者: + avatar +
+
+
+ @@ -193,6 +226,7 @@ export default { document.title = defaultTitle if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription) window.removeEventListener('scroll', updateCurrentIndex) + if (countdownTimer) clearInterval(countdownTimer) }) } @@ -202,6 +236,45 @@ export default { const loggedIn = computed(() => authState.loggedIn) const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) + const lottery = ref(null) + 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 updateCountdown = () => { + if (!lottery.value || !lottery.value.endTime) { + countdown.value = '00:00:00' + return + } + const diff = new Date(lottery.value.endTime).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 (!process.client) return + if (countdownTimer) clearInterval(countdownTimer) + updateCountdown() + countdownTimer = setInterval(updateCountdown, 1000) + } + const gotoUser = id => router.push(`/users/${id}`) const articleMenuItems = computed(() => { const items = [] if (isAuthor.value || isAdmin.value) { @@ -336,6 +409,8 @@ export default { status.value = data.status pinnedAt.value = data.pinnedAt postTime.value = TimeManager.format(data.createdAt) + lottery.value = data.lottery || null + if (lottery.value && lottery.value.endTime) startCountdown() await nextTick() } catch (e) { console.error(e) @@ -552,6 +627,24 @@ export default { } } + 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}` } + }) + if (res.ok) { + toast.success('已参与抽奖') + await fetchPost() + } else { + toast.error('操作失败') + } + } + const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -639,10 +732,12 @@ export default { copyPostLink, subscribePost, unsubscribePost, + joinLottery, renderMarkdown, isWaitingFetchingPost, isWaitingPostingComment, gotoProfile, + gotoUser, subscribed, loggedIn, isAuthor, @@ -663,9 +758,14 @@ export default { pinnedAt, commentSort, fetchCommentSorts, - isFetchingComments - , - getMedalTitle + isFetchingComments, + getMedalTitle, + lottery, + countdown, + lotteryParticipants, + lotteryWinners, + lotteryEnded, + hasJoined } } } @@ -1011,6 +1111,127 @@ export default { position: relative; } +.prize-container { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; + background-color: var(--normal-background-color); + padding: 10px; +} + +.prize-info { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; +} + +.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; +} + +.prize-end-time-title { + font-size: 13px; + opacity: 0.7; + margin-right: 5px; +} + +.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; +} + +.join-prize-button:hover { + background-color: var(--primary-color-hover); +} + +.join-prize-button.disabled { + background-color: var(--background-color-disabled); + cursor: not-allowed; +} + +.join-prize-button.disabled:hover { + background-color: var(--background-color-disabled); + cursor: not-allowed; +} + +.prize-member-avatar { + width: 30px; + height: 30px; + margin-left: 3px; + border-radius: 50%; +} + +.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);