From eb32e4bad7e1b6cf2ca0f1d1f3cb304ec2bd12cf Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:17:55 +0800 Subject: [PATCH 1/9] feat: implement lottery post type --- .../com/openisle/config/SchedulerConfig.java | 20 +++++ .../openisle/controller/PostController.java | 10 ++- .../java/com/openisle/dto/LotteryDto.java | 17 ++++ .../java/com/openisle/dto/PostRequest.java | 11 +++ .../java/com/openisle/dto/PostSummaryDto.java | 3 + .../java/com/openisle/mapper/PostMapper.java | 15 ++++ .../java/com/openisle/model/LotteryPost.java | 46 ++++++++++ .../main/java/com/openisle/model/Post.java | 9 +- .../java/com/openisle/model/PostType.java | 6 ++ .../repository/LotteryPostRepository.java | 7 ++ .../com/openisle/service/PostService.java | 84 ++++++++++++++++++- 11 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/openisle/config/SchedulerConfig.java create mode 100644 backend/src/main/java/com/openisle/dto/LotteryDto.java create mode 100644 backend/src/main/java/com/openisle/model/LotteryPost.java create mode 100644 backend/src/main/java/com/openisle/model/PostType.java create mode 100644 backend/src/main/java/com/openisle/repository/LotteryPostRepository.java 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) From 7616a2d0e0776a95c2049991e1866f332fea28e0 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:29:22 +0800 Subject: [PATCH 2/9] feat: add lottery post options --- frontend_nuxt/pages/new-post.vue | 136 ++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 93f9b4211..ca79c55bf 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -10,6 +10,10 @@
+
@@ -32,6 +36,21 @@
发布中...
+ +
+
+ + +
+
+ + + +
+
+ +
+
@@ -56,6 +75,10 @@ export default { const isWaitingPosting = ref(false) const isAiLoading = ref(false) const isLogin = computed(() => authState.loggedIn) + const postType = ref('NORMAL') + const prizeIcon = ref('') + const prizeCount = ref(1) + const lotteryEndTime = ref('') const loadDraft = async () => { const token = getToken() @@ -85,6 +108,10 @@ export default { content.value = '' selectedCategory.value = '' selectedTags.value = [] + postType.value = 'NORMAL' + prizeIcon.value = '' + prizeCount.value = 1 + lotteryEndTime.value = '' // 删除草稿 const token = getToken() @@ -213,6 +240,20 @@ export default { toast.error('请选择标签') return } + if (postType.value === 'LOTTERY') { + if (!prizeIcon.value) { + toast.error('请上传奖品图片') + return + } + if (!prizeCount.value || prizeCount.value <= 0) { + toast.error('奖品数量必须大于0') + return + } + if (!lotteryEndTime.value) { + toast.error('请选择抽奖结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -227,7 +268,14 @@ export default { title: title.value, content: content.value, categoryId: selectedCategory.value, - tagIds: selectedTags.value + tagIds: selectedTags.value, + type: postType.value, + prizeIcon: postType.value === 'LOTTERY' ? prizeIcon.value : undefined, + prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, + endTime: + postType.value === 'LOTTERY' && lotteryEndTime.value + ? new Date(lotteryEndTime.value).toISOString() + : undefined }) }) const data = await res.json() @@ -251,7 +299,55 @@ export default { isWaitingPosting.value = false } } - return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin } + const incPrizeCount = () => { + prizeCount.value += 1 + } + const decPrizeCount = () => { + if (prizeCount.value > 1) prizeCount.value -= 1 + } + const handlePrizeImage = async (e) => { + const file = e.target.files[0] + if (!file) return + try { + const res = await fetch(`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, { + headers: { Authorization: `Bearer ${getToken()}` } + }) + if (!res.ok) { + toast.error('获取上传地址失败') + return + } + const info = await res.json() + const put = await fetch(info.uploadUrl, { method: 'PUT', body: file }) + if (!put.ok) { + toast.error('上传失败') + return + } + prizeIcon.value = info.fileUrl + toast.success('上传成功') + } catch (e) { + toast.error('上传失败') + } + } + return { + title, + content, + selectedCategory, + selectedTags, + submitPost, + saveDraft, + clearPost, + isWaitingPosting, + aiGenerate, + isAiLoading, + isLogin, + postType, + prizeIcon, + prizeCount, + lotteryEndTime, + incPrizeCount, + decPrizeCount, + handlePrizeImage + } } } @@ -366,6 +462,42 @@ export default { padding-bottom: 50px; } +.post-type-select { + padding: 5px; + border-radius: 5px; +} + +.lottery-options { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.prize-image input { + display: block; +} + +.prize-preview { + margin-top: 10px; + max-width: 200px; +} + +.prize-count { + display: flex; + align-items: center; + gap: 5px; +} + +.prize-count button { + padding: 5px 10px; +} + +.lottery-end-time input { + padding: 5px; + border-radius: 5px; +} + @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); From 71e0b1379cf48c4d4fdafc303e22a83353834a78 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:30:36 +0800 Subject: [PATCH 3/9] Revert "feat: add lottery post options" --- frontend_nuxt/pages/new-post.vue | 136 +------------------------------ 1 file changed, 2 insertions(+), 134 deletions(-) diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index ca79c55bf..93f9b4211 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -10,10 +10,6 @@
-
@@ -36,21 +32,6 @@
发布中...
- -
-
- - -
-
- - - -
-
- -
-
@@ -75,10 +56,6 @@ export default { const isWaitingPosting = ref(false) const isAiLoading = ref(false) const isLogin = computed(() => authState.loggedIn) - const postType = ref('NORMAL') - const prizeIcon = ref('') - const prizeCount = ref(1) - const lotteryEndTime = ref('') const loadDraft = async () => { const token = getToken() @@ -108,10 +85,6 @@ export default { content.value = '' selectedCategory.value = '' selectedTags.value = [] - postType.value = 'NORMAL' - prizeIcon.value = '' - prizeCount.value = 1 - lotteryEndTime.value = '' // 删除草稿 const token = getToken() @@ -240,20 +213,6 @@ export default { toast.error('请选择标签') return } - if (postType.value === 'LOTTERY') { - if (!prizeIcon.value) { - toast.error('请上传奖品图片') - return - } - if (!prizeCount.value || prizeCount.value <= 0) { - toast.error('奖品数量必须大于0') - return - } - if (!lotteryEndTime.value) { - toast.error('请选择抽奖结束时间') - return - } - } try { const token = getToken() await ensureTags(token) @@ -268,14 +227,7 @@ export default { title: title.value, content: content.value, categoryId: selectedCategory.value, - tagIds: selectedTags.value, - type: postType.value, - prizeIcon: postType.value === 'LOTTERY' ? prizeIcon.value : undefined, - prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, - endTime: - postType.value === 'LOTTERY' && lotteryEndTime.value - ? new Date(lotteryEndTime.value).toISOString() - : undefined + tagIds: selectedTags.value }) }) const data = await res.json() @@ -299,55 +251,7 @@ export default { isWaitingPosting.value = false } } - const incPrizeCount = () => { - prizeCount.value += 1 - } - const decPrizeCount = () => { - if (prizeCount.value > 1) prizeCount.value -= 1 - } - const handlePrizeImage = async (e) => { - const file = e.target.files[0] - if (!file) return - try { - const res = await fetch(`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, { - headers: { Authorization: `Bearer ${getToken()}` } - }) - if (!res.ok) { - toast.error('获取上传地址失败') - return - } - const info = await res.json() - const put = await fetch(info.uploadUrl, { method: 'PUT', body: file }) - if (!put.ok) { - toast.error('上传失败') - return - } - prizeIcon.value = info.fileUrl - toast.success('上传成功') - } catch (e) { - toast.error('上传失败') - } - } - return { - title, - content, - selectedCategory, - selectedTags, - submitPost, - saveDraft, - clearPost, - isWaitingPosting, - aiGenerate, - isAiLoading, - isLogin, - postType, - prizeIcon, - prizeCount, - lotteryEndTime, - incPrizeCount, - decPrizeCount, - handlePrizeImage - } + return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin } } } @@ -462,42 +366,6 @@ export default { padding-bottom: 50px; } -.post-type-select { - padding: 5px; - border-radius: 5px; -} - -.lottery-options { - margin-top: 20px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.prize-image input { - display: block; -} - -.prize-preview { - margin-top: 10px; - max-width: 200px; -} - -.prize-count { - display: flex; - align-items: center; - gap: 5px; -} - -.prize-count button { - padding: 5px 10px; -} - -.lottery-end-time input { - padding: 5px; - border-radius: 5px; -} - @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); From 398226b9bcaddb7a8f9fce9cc23cdea5f2886876 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:32:12 +0800 Subject: [PATCH 4/9] feat: add lottery post fields --- frontend_nuxt/components/PostTypeSelect.vue | 27 ++++ frontend_nuxt/pages/new-post.vue | 141 +++++++++++++++++++- 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 frontend_nuxt/components/PostTypeSelect.vue diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue new file mode 100644 index 000000000..0537e30bc --- /dev/null +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 93f9b4211..6ff97c9df 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -8,6 +8,7 @@
+
@@ -32,6 +33,25 @@
发布中...
+
+
+ + + +
+
+ +
+ + + +
+
+
+ + +
+
@@ -40,6 +60,7 @@ import { ref, onMounted, computed } from 'vue' import PostEditor from '../components/PostEditor.vue' import CategorySelect from '../components/CategorySelect.vue' +import PostTypeSelect from '../components/PostTypeSelect.vue' import TagSelect from '../components/TagSelect.vue' import { API_BASE_URL, toast } from '../main' import { getToken, authState } from '../utils/auth' @@ -47,12 +68,16 @@ import LoginOverlay from '../components/LoginOverlay.vue' export default { name: 'NewPostPageView', - components: { PostEditor, CategorySelect, TagSelect, LoginOverlay }, + components: { PostEditor, CategorySelect, PostTypeSelect, TagSelect, LoginOverlay }, setup() { const title = ref('') const content = ref('') const selectedCategory = ref('') const selectedTags = ref([]) + const postType = ref('NORMAL') + const prizeIcon = ref('') + const prizeCount = ref(1) + const endTime = ref('') const isWaitingPosting = ref(false) const isAiLoading = ref(false) const isLogin = computed(() => authState.loggedIn) @@ -85,6 +110,10 @@ export default { content.value = '' selectedCategory.value = '' selectedTags.value = [] + postType.value = 'NORMAL' + prizeIcon.value = '' + prizeCount.value = 1 + endTime.value = '' // 删除草稿 const token = getToken() @@ -164,6 +193,39 @@ export default { } } + const uploadPrizeIcon = async (e) => { + const file = e.target.files[0] + if (!file) return + try { + const token = getToken() + const res = await fetch(`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, { + headers: { Authorization: `Bearer ${token}` } + }) + if (!res.ok) { + toast.error('获取上传地址失败') + return + } + const info = await res.json() + const put = await fetch(info.uploadUrl, { method: 'PUT', body: file }) + if (!put.ok) { + toast.error('上传失败') + return + } + prizeIcon.value = info.fileUrl + toast.success('上传成功') + } catch (err) { + toast.error('上传失败') + } + } + + const increasePrizeCount = () => { + prizeCount.value++ + } + + const decreasePrizeCount = () => { + if (prizeCount.value > 1) prizeCount.value-- + } + const aiGenerate = async () => { if (!content.value.trim()) { toast.error('内容为空,无法优化') @@ -213,6 +275,20 @@ export default { toast.error('请选择标签') return } + if (postType.value === 'LOTTERY') { + if (!prizeIcon.value) { + toast.error('请上传奖品图片') + return + } + if (!prizeCount.value || prizeCount.value <= 0) { + toast.error('奖品数量必须大于0') + return + } + if (!endTime.value) { + toast.error('请选择抽奖结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -227,7 +303,13 @@ export default { title: title.value, content: content.value, categoryId: selectedCategory.value, - tagIds: selectedTags.value + tagIds: selectedTags.value, + type: postType.value, + prizeIcon: postType.value === 'LOTTERY' ? prizeIcon.value : null, + prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : null, + startTime: postType.value === 'LOTTERY' ? new Date().toISOString() : null, + endTime: postType.value === 'LOTTERY' ? new Date(endTime.value).toISOString() : null, + prizeDescription: postType.value === 'LOTTERY' ? '' : null }) }) const data = await res.json() @@ -251,7 +333,26 @@ export default { isWaitingPosting.value = false } } - return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin } + return { + title, + content, + selectedCategory, + selectedTags, + postType, + prizeIcon, + prizeCount, + endTime, + submitPost, + saveDraft, + clearPost, + isWaitingPosting, + aiGenerate, + isAiLoading, + isLogin, + uploadPrizeIcon, + increasePrizeCount, + decreasePrizeCount + } } } @@ -366,6 +467,40 @@ export default { padding-bottom: 50px; } +.lottery-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; +} + +.lottery-field { + display: flex; + flex-direction: column; + gap: 5px; +} + +.prize-preview { + max-width: 200px; + margin-top: 5px; +} + +.prize-count-input { + display: flex; + align-items: center; + gap: 5px; +} + +.prize-count-input input { + width: 60px; + text-align: center; +} + +.prize-count-input button { + width: 30px; + height: 30px; +} + @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); From 6fb16e91dc8d49f575fe8a11c8c942472f7c27c2 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:32:50 +0800 Subject: [PATCH 5/9] Revert "feat: add lottery post fields" --- frontend_nuxt/components/PostTypeSelect.vue | 27 ---- frontend_nuxt/pages/new-post.vue | 141 +------------------- 2 files changed, 3 insertions(+), 165 deletions(-) delete mode 100644 frontend_nuxt/components/PostTypeSelect.vue diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue deleted file mode 100644 index 0537e30bc..000000000 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ /dev/null @@ -1,27 +0,0 @@ - - - diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 6ff97c9df..93f9b4211 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -8,7 +8,6 @@
-
@@ -33,25 +32,6 @@
发布中...
-
-
- - - -
-
- -
- - - -
-
-
- - -
-
@@ -60,7 +40,6 @@ import { ref, onMounted, computed } from 'vue' import PostEditor from '../components/PostEditor.vue' import CategorySelect from '../components/CategorySelect.vue' -import PostTypeSelect from '../components/PostTypeSelect.vue' import TagSelect from '../components/TagSelect.vue' import { API_BASE_URL, toast } from '../main' import { getToken, authState } from '../utils/auth' @@ -68,16 +47,12 @@ import LoginOverlay from '../components/LoginOverlay.vue' export default { name: 'NewPostPageView', - components: { PostEditor, CategorySelect, PostTypeSelect, TagSelect, LoginOverlay }, + components: { PostEditor, CategorySelect, TagSelect, LoginOverlay }, setup() { const title = ref('') const content = ref('') const selectedCategory = ref('') const selectedTags = ref([]) - const postType = ref('NORMAL') - const prizeIcon = ref('') - const prizeCount = ref(1) - const endTime = ref('') const isWaitingPosting = ref(false) const isAiLoading = ref(false) const isLogin = computed(() => authState.loggedIn) @@ -110,10 +85,6 @@ export default { content.value = '' selectedCategory.value = '' selectedTags.value = [] - postType.value = 'NORMAL' - prizeIcon.value = '' - prizeCount.value = 1 - endTime.value = '' // 删除草稿 const token = getToken() @@ -193,39 +164,6 @@ export default { } } - const uploadPrizeIcon = async (e) => { - const file = e.target.files[0] - if (!file) return - try { - const token = getToken() - const res = await fetch(`${API_BASE_URL}/api/upload/presign?filename=${encodeURIComponent(file.name)}`, { - headers: { Authorization: `Bearer ${token}` } - }) - if (!res.ok) { - toast.error('获取上传地址失败') - return - } - const info = await res.json() - const put = await fetch(info.uploadUrl, { method: 'PUT', body: file }) - if (!put.ok) { - toast.error('上传失败') - return - } - prizeIcon.value = info.fileUrl - toast.success('上传成功') - } catch (err) { - toast.error('上传失败') - } - } - - const increasePrizeCount = () => { - prizeCount.value++ - } - - const decreasePrizeCount = () => { - if (prizeCount.value > 1) prizeCount.value-- - } - const aiGenerate = async () => { if (!content.value.trim()) { toast.error('内容为空,无法优化') @@ -275,20 +213,6 @@ export default { toast.error('请选择标签') return } - if (postType.value === 'LOTTERY') { - if (!prizeIcon.value) { - toast.error('请上传奖品图片') - return - } - if (!prizeCount.value || prizeCount.value <= 0) { - toast.error('奖品数量必须大于0') - return - } - if (!endTime.value) { - toast.error('请选择抽奖结束时间') - return - } - } try { const token = getToken() await ensureTags(token) @@ -303,13 +227,7 @@ export default { title: title.value, content: content.value, categoryId: selectedCategory.value, - tagIds: selectedTags.value, - type: postType.value, - prizeIcon: postType.value === 'LOTTERY' ? prizeIcon.value : null, - prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : null, - startTime: postType.value === 'LOTTERY' ? new Date().toISOString() : null, - endTime: postType.value === 'LOTTERY' ? new Date(endTime.value).toISOString() : null, - prizeDescription: postType.value === 'LOTTERY' ? '' : null + tagIds: selectedTags.value }) }) const data = await res.json() @@ -333,26 +251,7 @@ export default { isWaitingPosting.value = false } } - return { - title, - content, - selectedCategory, - selectedTags, - postType, - prizeIcon, - prizeCount, - endTime, - submitPost, - saveDraft, - clearPost, - isWaitingPosting, - aiGenerate, - isAiLoading, - isLogin, - uploadPrizeIcon, - increasePrizeCount, - decreasePrizeCount - } + return { title, content, selectedCategory, selectedTags, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin } } } @@ -467,40 +366,6 @@ export default { padding-bottom: 50px; } -.lottery-options { - display: flex; - flex-direction: column; - gap: 10px; - margin-top: 20px; -} - -.lottery-field { - display: flex; - flex-direction: column; - gap: 5px; -} - -.prize-preview { - max-width: 200px; - margin-top: 5px; -} - -.prize-count-input { - display: flex; - align-items: center; - gap: 5px; -} - -.prize-count-input input { - width: 60px; - text-align: center; -} - -.prize-count-input button { - width: 30px; - height: 30px; -} - @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); From e0291868bc851640a43f7033b77a7040d0d5e6e1 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 01:45:23 +0800 Subject: [PATCH 6/9] feat: add lottery post type options --- frontend_nuxt/components/PostTypeSelect.vue | 46 +++++ frontend_nuxt/package-lock.json | 23 +++ frontend_nuxt/package.json | 4 +- frontend_nuxt/pages/new-post.vue | 191 +++++++++++++++++++- 4 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 frontend_nuxt/components/PostTypeSelect.vue 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/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..51e1bee8e 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -10,6 +10,7 @@
+
@@ -32,31 +33,101 @@
发布中...
+
+ +
+ +
+
+ 奖品数量 +
+ + + +
+
+
+ 抽奖结束时间 + + + +
+
@@ -366,6 +478,77 @@ 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; +} + +.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; + gap: 5px; +} + +.prize-count-input button { + width: 24px; + height: 24px; +} + +.time-picker { + max-width: 200px; +} + @media (max-width: 768px) { .new-post-page { width: calc(100vw - 20px); From 8320a84ba07404b393af4336b7ccd8fb541e1b96 Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 11 Aug 2025 02:22:38 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=E6=8A=BD=E5=A5=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/pages/new-post.vue | 55 +++++--- frontend_nuxt/pages/posts/[id]/index.vue | 170 +++++++++++++++++++++++ 2 files changed, 209 insertions(+), 16 deletions(-) diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 51e1bee8e..8a5ddabf2 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -43,16 +43,19 @@
+
+ 奖品名称 + +
奖品数量
- - - +
@@ -78,6 +81,7 @@ import 'flatpickr/dist/flatpickr.css' import { API_BASE_URL, toast } from '../main' import { getToken, authState } from '../utils/auth' import LoginOverlay from '../components/LoginOverlay.vue' +import BaseInput from '../components/BaseInput.vue' export default { name: 'NewPostPageView', @@ -92,6 +96,7 @@ export default { const prizeIconFile = ref(null) const tempPrizeIcon = ref('') const showPrizeCropper = ref(false) + const prizeName = ref('') const prizeCount = ref(1) const endTime = ref(null) const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' } @@ -116,14 +121,6 @@ export default { prizeIcon.value = url } - const incPrizeCount = () => { - prizeCount.value++ - } - - const decPrizeCount = () => { - if (prizeCount.value > 1) prizeCount.value-- - } - watch(prizeCount, val => { if (!val || val < 1) prizeCount.value = 1 }) @@ -300,6 +297,10 @@ export default { toast.error('奖品数量必须大于0') return } + if (!prizeName.value) { + toast.error('请输入奖品名称') + return + } if (!endTime.value) { toast.error('请选择抽奖结束时间') return @@ -338,6 +339,7 @@ export default { tagIds: selectedTags.value, type: postType.value, prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined, + prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined, prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, endTime: postType.value === 'LOTTERY' ? new Date(endTime.value).toISOString() : undefined }) @@ -363,7 +365,7 @@ export default { isWaitingPosting.value = false } } - return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, incPrizeCount, decPrizeCount, showPrizeCropper, tempPrizeIcon, dateConfig } + return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, showPrizeCropper, tempPrizeIcon, dateConfig, prizeName } } } @@ -498,6 +500,12 @@ export default { cursor: pointer; } +.default-prize-icon { + font-size: 100px; + opacity: 0.5; + color: var(--text-color); +} + .prize-preview { width: 100%; height: 100%; @@ -537,16 +545,31 @@ export default { .prize-count-input { display: flex; align-items: center; - gap: 5px; } -.prize-count-input button { - width: 24px; - height: 24px; +.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) { diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 174ddf450..be2e9da9f 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -77,6 +77,71 @@
+
+
+
+
+
+ prize + +
+
ChatGPT Plus For 1 month
+
x 12
+
+
+
离结束还有
+
12:00:00
+ +
+
参与抽奖
+
+
+
+
+
+ avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + avatar + +
+ + 获奖者: + avatar +
+
+
+ @@ -1011,6 +1076,111 @@ 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-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); +} + +.prize-member-avatar { + width: 30px; + height: 30px; + 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); From 6b1aeb82c12c862754e0ada582bfff81327973af Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 11 Aug 2025 02:34:54 +0800 Subject: [PATCH 8/9] feat: add lottery section logic --- frontend_nuxt/pages/posts/[id]/index.vue | 129 ++++++++++++++--------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index be2e9da9f..180a87a2d 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -77,67 +77,35 @@ -
+
- prize + prize
-
ChatGPT Plus For 1 month
-
x 12
+
{{ lottery.prizeDescription }}
+
x {{ lottery.prizeCount }}
离结束还有
-
12:00:00
- -
+
{{ countdown }}
+
参与抽奖
+
+
已参与
+
- avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - avatar - -
+ avatar +
获奖者: - avatar + avatar
@@ -258,6 +226,7 @@ export default { document.title = defaultTitle if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription) window.removeEventListener('scroll', updateCurrentIndex) + if (countdownTimer) clearInterval(countdownTimer) }) } @@ -267,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) { @@ -401,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) @@ -617,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' }, @@ -704,10 +732,12 @@ export default { copyPostLink, subscribePost, unsubscribePost, + joinLottery, renderMarkdown, isWaitingFetchingPost, isWaitingPostingComment, gotoProfile, + gotoUser, subscribed, loggedIn, isAuthor, @@ -728,9 +758,14 @@ export default { pinnedAt, commentSort, fetchCommentSorts, - isFetchingComments - , - getMedalTitle + isFetchingComments, + getMedalTitle, + lottery, + countdown, + lotteryParticipants, + lotteryWinners, + lotteryEnded, + hasJoined } } } From 536052932786cbe73b7bc0ba5cd29d8fb0edf42c Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 11 Aug 2025 09:56:15 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E6=8A=BD=E5=A5=96ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend_nuxt/main.js | 4 ++-- frontend_nuxt/pages/new-post.vue | 19 +++++++++++++------ frontend_nuxt/pages/posts/[id]/index.vue | 20 ++++++++++++++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) 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/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 8a5ddabf2..3319f0d0e 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -49,8 +49,8 @@
- 奖品名称 - + 奖品描述 +
奖品数量 @@ -98,7 +98,9 @@ export default { const showPrizeCropper = ref(false) const prizeName = ref('') const prizeCount = ref(1) + const prizeDescription = ref('') const endTime = ref(null) + const startTime = ref(null) const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' } const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -158,8 +160,10 @@ export default { prizeIconFile.value = null tempPrizeIcon.value = '' showPrizeCropper.value = false + prizeDescription.value = '' prizeCount.value = 1 endTime.value = null + startTime.value = null // 删除草稿 const token = getToken() @@ -297,8 +301,8 @@ export default { toast.error('奖品数量必须大于0') return } - if (!prizeName.value) { - toast.error('请输入奖品名称') + if (!prizeDescription.value) { + toast.error('请输入奖品描述') return } if (!endTime.value) { @@ -341,7 +345,10 @@ export default { prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined, prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined, prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, - endTime: postType.value === 'LOTTERY' ? new Date(endTime.value).toISOString() : undefined + prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined, + startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, + // 将时间转换为 UTC+8.5 时区 todo: 需要优化 + endTime: postType.value === 'LOTTERY' ? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : undefined }) }) const data = await res.json() @@ -365,7 +372,7 @@ export default { isWaitingPosting.value = false } } - return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, showPrizeCropper, tempPrizeIcon, dateConfig, prizeName } + return { title, content, selectedCategory, selectedTags, postType, prizeIcon, prizeCount, endTime, submitPost, saveDraft, clearPost, isWaitingPosting, aiGenerate, isAiLoading, isLogin, onPrizeIconChange, onPrizeCropped, showPrizeCropper, tempPrizeIcon, dateConfig, prizeName, prizeDescription } } } diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index 180a87a2d..dec958de9 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -82,7 +82,7 @@
- prize + prize
{{ lottery.prizeDescription }}
@@ -94,7 +94,7 @@
参与抽奖
-
+
已参与
@@ -1137,6 +1137,11 @@ export default { opacity: 0.5; } +.prize-icon-img { + width: 100%; + height: 100%; +} + .prize-name { font-size: 13px; opacity: 0.7; @@ -1192,9 +1197,20 @@ export default { 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%; }