mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-07-21 20:16:13 +00:00
Merge pull request #470 from nagisa77/codex/add-lottery-post-type-and-api
feat: add lottery post type with participation API
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ public class PostController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
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());
|
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()));
|
||||||
@@ -67,6 +69,12 @@ public class PostController {
|
|||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
|
return ResponseEntity.ok(postMapper.toDetailDto(post, viewer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/lottery/join")
|
||||||
|
public ResponseEntity<Void> joinLottery(@PathVariable Long id, Authentication auth) {
|
||||||
|
postService.joinLottery(id, auth.getName());
|
||||||
|
return ResponseEntity.ok().build();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||||
|
|||||||
@@ -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<AuthorDto> participants;
|
||||||
|
private List<AuthorDto> winners;
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package com.openisle.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.openisle.model.PostType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request body for creating or updating a post.
|
* Request body for creating or updating a post.
|
||||||
*/
|
*/
|
||||||
@@ -14,5 +17,13 @@ public class PostRequest {
|
|||||||
private String content;
|
private String content;
|
||||||
private List<Long> tagIds;
|
private List<Long> tagIds;
|
||||||
private String captcha;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import com.openisle.model.PostStatus;
|
import com.openisle.model.PostStatus;
|
||||||
|
import com.openisle.model.PostType;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -28,5 +29,7 @@ public class PostSummaryDto {
|
|||||||
private boolean subscribed;
|
private boolean subscribed;
|
||||||
private int reward;
|
private int reward;
|
||||||
private int pointReward;
|
private int pointReward;
|
||||||
|
private PostType type;
|
||||||
|
private LotteryDto lottery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import com.openisle.dto.CommentDto;
|
|||||||
import com.openisle.dto.PostDetailDto;
|
import com.openisle.dto.PostDetailDto;
|
||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
import com.openisle.dto.ReactionDto;
|
import com.openisle.dto.ReactionDto;
|
||||||
|
import com.openisle.dto.LotteryDto;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.service.CommentService;
|
import com.openisle.service.CommentService;
|
||||||
import com.openisle.service.ReactionService;
|
import com.openisle.service.ReactionService;
|
||||||
@@ -75,5 +77,18 @@ public class PostMapper {
|
|||||||
dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
|
dto.setLastReplyAt(last != null ? last : post.getCreatedAt());
|
||||||
dto.setReward(0);
|
dto.setReward(0);
|
||||||
dto.setSubscribed(false);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<User> participants = new HashSet<>();
|
||||||
|
|
||||||
|
@ManyToMany
|
||||||
|
@JoinTable(name = "lottery_winners",
|
||||||
|
joinColumns = @JoinColumn(name = "post_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
||||||
|
private Set<User> winners = new HashSet<>();
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@ import org.hibernate.annotations.CreationTimestamp;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
import com.openisle.model.Tag;
|
import com.openisle.model.Tag;
|
||||||
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post entity representing an article posted by a user.
|
* Post entity representing an article posted by a user.
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +22,7 @@ import java.time.LocalDateTime;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "posts")
|
@Table(name = "posts")
|
||||||
|
@Inheritance(strategy = InheritanceType.JOINED)
|
||||||
public class Post {
|
public class Post {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -59,6 +60,10 @@ public class Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PostStatus status = PostStatus.PUBLISHED;
|
private PostStatus status = PostStatus.PUBLISHED;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private PostType type = PostType.NORMAL;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private LocalDateTime pinnedAt;
|
private LocalDateTime pinnedAt;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
public enum PostType {
|
||||||
|
NORMAL,
|
||||||
|
LOTTERY
|
||||||
|
}
|
||||||
@@ -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<LotteryPost, Long> {
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ package com.openisle.service;
|
|||||||
|
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.PostStatus;
|
import com.openisle.model.PostStatus;
|
||||||
|
import com.openisle.model.PostType;
|
||||||
import com.openisle.model.PublishMode;
|
import com.openisle.model.PublishMode;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.model.Category;
|
import com.openisle.model.Category;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.NotificationType;
|
import com.openisle.model.NotificationType;
|
||||||
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
|
import com.openisle.repository.LotteryPostRepository;
|
||||||
import com.openisle.repository.UserRepository;
|
import com.openisle.repository.UserRepository;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import com.openisle.repository.TagRepository;
|
import com.openisle.repository.TagRepository;
|
||||||
@@ -21,6 +24,8 @@ import com.openisle.model.Role;
|
|||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
|
import com.openisle.service.EmailSender;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.domain.PageRequest;
|
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.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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
|
@Service
|
||||||
public class PostService {
|
public class PostService {
|
||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final CategoryRepository categoryRepository;
|
private final CategoryRepository categoryRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
private final LotteryPostRepository lotteryPostRepository;
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final SubscriptionService subscriptionService;
|
private final SubscriptionService subscriptionService;
|
||||||
@@ -44,12 +56,15 @@ public class PostService {
|
|||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final PostReadService postReadService;
|
private final PostReadService postReadService;
|
||||||
private final ImageUploader imageUploader;
|
private final ImageUploader imageUploader;
|
||||||
|
private final TaskScheduler taskScheduler;
|
||||||
|
private final EmailSender emailSender;
|
||||||
|
|
||||||
@org.springframework.beans.factory.annotation.Autowired
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
public PostService(PostRepository postRepository,
|
public PostService(PostRepository postRepository,
|
||||||
UserRepository userRepository,
|
UserRepository userRepository,
|
||||||
CategoryRepository categoryRepository,
|
CategoryRepository categoryRepository,
|
||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
|
LotteryPostRepository lotteryPostRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
SubscriptionService subscriptionService,
|
SubscriptionService subscriptionService,
|
||||||
CommentService commentService,
|
CommentService commentService,
|
||||||
@@ -59,11 +74,14 @@ public class PostService {
|
|||||||
NotificationRepository notificationRepository,
|
NotificationRepository notificationRepository,
|
||||||
PostReadService postReadService,
|
PostReadService postReadService,
|
||||||
ImageUploader imageUploader,
|
ImageUploader imageUploader,
|
||||||
|
TaskScheduler taskScheduler,
|
||||||
|
EmailSender emailSender,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.categoryRepository = categoryRepository;
|
this.categoryRepository = categoryRepository;
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
|
this.lotteryPostRepository = lotteryPostRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.subscriptionService = subscriptionService;
|
this.subscriptionService = subscriptionService;
|
||||||
this.commentService = commentService;
|
this.commentService = commentService;
|
||||||
@@ -73,6 +91,8 @@ public class PostService {
|
|||||||
this.notificationRepository = notificationRepository;
|
this.notificationRepository = notificationRepository;
|
||||||
this.postReadService = postReadService;
|
this.postReadService = postReadService;
|
||||||
this.imageUploader = imageUploader;
|
this.imageUploader = imageUploader;
|
||||||
|
this.taskScheduler = taskScheduler;
|
||||||
|
this.emailSender = emailSender;
|
||||||
this.publishMode = publishMode;
|
this.publishMode = publishMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +108,13 @@ public class PostService {
|
|||||||
Long categoryId,
|
Long categoryId,
|
||||||
String title,
|
String title,
|
||||||
String content,
|
String content,
|
||||||
java.util.List<Long> tagIds) {
|
java.util.List<Long> tagIds,
|
||||||
|
PostType type,
|
||||||
|
String prizeDescription,
|
||||||
|
String prizeIcon,
|
||||||
|
Integer prizeCount,
|
||||||
|
LocalDateTime startTime,
|
||||||
|
LocalDateTime endTime) {
|
||||||
long recent = postRepository.countByAuthorAfter(username,
|
long recent = postRepository.countByAuthorAfter(username,
|
||||||
java.time.LocalDateTime.now().minusMinutes(5));
|
java.time.LocalDateTime.now().minusMinutes(5));
|
||||||
if (recent >= 1) {
|
if (recent >= 1) {
|
||||||
@@ -108,14 +134,31 @@ public class PostService {
|
|||||||
if (tags.isEmpty()) {
|
if (tags.isEmpty()) {
|
||||||
throw new IllegalArgumentException("Tag not found");
|
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.setTitle(title);
|
||||||
post.setContent(content);
|
post.setContent(content);
|
||||||
post.setAuthor(author);
|
post.setAuthor(author);
|
||||||
post.setCategory(category);
|
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.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));
|
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||||
if (post.getStatus() == PostStatus.PENDING) {
|
if (post.getStatus() == PostStatus.PENDING) {
|
||||||
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
|
java.util.List<User> admins = userRepository.findByRole(com.openisle.model.Role.ADMIN);
|
||||||
@@ -141,9 +184,42 @@ public class PostService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
notificationService.notifyMentions(content, author, post, null);
|
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;
|
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<User> participants = new ArrayList<>(lp.getParticipants());
|
||||||
|
if (participants.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Collections.shuffle(participants);
|
||||||
|
int winnersCount = Math.min(lp.getPrizeCount(), participants.size());
|
||||||
|
java.util.Set<User> 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
|
@Transactional
|
||||||
public Post viewPost(Long id, String viewer) {
|
public Post viewPost(Long id, String viewer) {
|
||||||
Post post = postRepository.findById(id)
|
Post post = postRepository.findById(id)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<template>
|
||||||
|
<Dropdown v-model="selected" :fetch-options="fetchTypes" placeholder="选择帖子类型" :initial-options="providedOptions" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PostTypeSelect',
|
||||||
|
components: { Dropdown },
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: 'NORMAL' },
|
||||||
|
options: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
val => {
|
||||||
|
providedOptions.value = Array.isArray(val) ? [...val] : []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchTypes = async () => {
|
||||||
|
return [
|
||||||
|
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||||
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: v => emit('update:modelValue', v)
|
||||||
|
})
|
||||||
|
|
||||||
|
return { fetchTypes, selected, providedOptions }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const API_BASE_URL = 'https://www.open-isle.com'
|
// 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://127.0.0.1:8081'
|
||||||
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
// export const API_BASE_URL = 'http://30.211.97.238:8081'
|
||||||
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
|
||||||
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
|
||||||
|
|||||||
Generated
+23
@@ -8,6 +8,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cropperjs": "^1.6.2",
|
"cropperjs": "^1.6.2",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
|
"flatpickr": "^4.6.13",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ldrs": "^1.0.0",
|
"ldrs": "^1.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
"vditor": "^3.11.1",
|
"vditor": "^3.11.1",
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
"vue-echarts": "^7.0.3",
|
"vue-echarts": "^7.0.3",
|
||||||
|
"vue-flatpickr-component": "^12.0.0",
|
||||||
"vue-toastification": "^2.0.0-rc.5"
|
"vue-toastification": "^2.0.0-rc.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4868,6 +4870,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/fn.name": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"vditor": "^3.11.1",
|
"vditor": "^3.11.1",
|
||||||
"vue-easy-lightbox": "^1.19.0",
|
"vue-easy-lightbox": "^1.19.0",
|
||||||
"vue-echarts": "^7.0.3",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="post-options-left">
|
<div class="post-options-left">
|
||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
|
<PostTypeSelect v-model="postType" />
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost">
|
<div class="post-clear" @click="clearPost">
|
||||||
@@ -32,31 +33,100 @@
|
|||||||
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
|
<div v-else class="post-submit-loading"> <i class="fa-solid fa-spinner fa-spin"></i> 发布中...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="postType === 'LOTTERY'" class="lottery-section">
|
||||||
|
<AvatarCropper
|
||||||
|
:src="tempPrizeIcon"
|
||||||
|
:show="showPrizeCropper"
|
||||||
|
@close="showPrizeCropper = false"
|
||||||
|
@crop="onPrizeCropped"
|
||||||
|
/>
|
||||||
|
<div class="prize-row">
|
||||||
|
<label class="prize-container">
|
||||||
|
<img 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>奖品描述</span>
|
||||||
|
<input class="prize-name-input" v-model="prizeDescription" placeholder="奖品描述" />
|
||||||
|
</div>
|
||||||
|
<div class="prize-count-row">
|
||||||
|
<span>奖品数量</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-time-row">
|
||||||
|
<span>抽奖结束时间</span>
|
||||||
|
<client-only>
|
||||||
|
<flat-pickr v-model="endTime" :config="dateConfig" class="time-picker" />
|
||||||
|
</client-only>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import PostEditor from '../components/PostEditor.vue'
|
import PostEditor from '../components/PostEditor.vue'
|
||||||
import CategorySelect from '../components/CategorySelect.vue'
|
import CategorySelect from '../components/CategorySelect.vue'
|
||||||
import TagSelect from '../components/TagSelect.vue'
|
import TagSelect from '../components/TagSelect.vue'
|
||||||
|
import PostTypeSelect from '../components/PostTypeSelect.vue'
|
||||||
|
import AvatarCropper from '../components/AvatarCropper.vue'
|
||||||
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '../main'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import { getToken, authState } from '../utils/auth'
|
||||||
import LoginOverlay from '../components/LoginOverlay.vue'
|
import LoginOverlay from '../components/LoginOverlay.vue'
|
||||||
|
import BaseInput from '../components/BaseInput.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'NewPostPageView',
|
name: 'NewPostPageView',
|
||||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay, PostTypeSelect, AvatarCropper, FlatPickr },
|
||||||
setup() {
|
setup() {
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const content = ref('')
|
const content = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
|
const postType = ref('NORMAL')
|
||||||
|
const prizeIcon = ref('')
|
||||||
|
const prizeIconFile = ref(null)
|
||||||
|
const tempPrizeIcon = ref('')
|
||||||
|
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 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
|
||||||
|
})
|
||||||
|
|
||||||
const loadDraft = async () => {
|
const loadDraft = async () => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return
|
if (!token) return
|
||||||
@@ -85,6 +155,15 @@ export default {
|
|||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
|
postType.value = 'NORMAL'
|
||||||
|
prizeIcon.value = ''
|
||||||
|
prizeIconFile.value = null
|
||||||
|
tempPrizeIcon.value = ''
|
||||||
|
showPrizeCropper.value = false
|
||||||
|
prizeDescription.value = ''
|
||||||
|
prizeCount.value = 1
|
||||||
|
endTime.value = null
|
||||||
|
startTime.value = null
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -213,10 +292,44 @@ export default {
|
|||||||
toast.error('请选择标签')
|
toast.error('请选择标签')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (postType.value === 'LOTTERY') {
|
||||||
|
if (!prizeIcon.value) {
|
||||||
|
toast.error('请上传奖品图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeCount.value || prizeCount.value < 1) {
|
||||||
|
toast.error('奖品数量必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prizeDescription.value) {
|
||||||
|
toast.error('请输入奖品描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!endTime.value) {
|
||||||
|
toast.error('请选择抽奖结束时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
isWaitingPosting.value = true
|
isWaitingPosting.value = true
|
||||||
|
let prizeIconUrl = prizeIcon.value
|
||||||
|
if (postType.value === 'LOTTERY' && prizeIconFile.value) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', prizeIconFile.value)
|
||||||
|
const uploadRes = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
const uploadData = await uploadRes.json()
|
||||||
|
if (!uploadRes.ok || uploadData.code !== 0) {
|
||||||
|
toast.error('奖品图片上传失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prizeIconUrl = uploadData.data.url
|
||||||
|
}
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -227,7 +340,15 @@ export default {
|
|||||||
title: title.value,
|
title: title.value,
|
||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value
|
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,
|
||||||
|
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()
|
const data = await res.json()
|
||||||
@@ -251,7 +372,7 @@ export default {
|
|||||||
isWaitingPosting.value = false
|
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, onPrizeIconChange, onPrizeCropped, showPrizeCropper, tempPrizeIcon, dateConfig, prizeName, prizeDescription }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -366,6 +487,98 @@ export default {
|
|||||||
padding-bottom: 50px;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.new-post-page {
|
.new-post-page {
|
||||||
width: calc(100vw - 20px);
|
width: calc(100vw - 20px);
|
||||||
|
|||||||
@@ -77,6 +77,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="lottery" class="prize-container">
|
||||||
|
<div class="prize-content">
|
||||||
|
<div class="prize-info">
|
||||||
|
<div class="prize-info-left">
|
||||||
|
<div class="prize-icon">
|
||||||
|
<img class="prize-icon-img" v-if="lottery.prizeIcon" :src="lottery.prizeIcon" alt="prize" />
|
||||||
|
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||||
|
</div>
|
||||||
|
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||||
|
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="prize-end-time prize-info-right">
|
||||||
|
<div class="prize-end-time-title">离结束还有</div>
|
||||||
|
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||||
|
<div v-if="loggedIn && !hasJoined && !lotteryEnded" class="join-prize-button" @click="joinLottery">
|
||||||
|
<div class="join-prize-button-text">参与抽奖</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hasJoined" class="join-prize-button disabled">
|
||||||
|
<div class="join-prize-button-text">已参与</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prize-member-container">
|
||||||
|
<img v-for="p in lotteryParticipants" :key="p.id" class="prize-member-avatar" :src="p.avatar" alt="avatar" @click="gotoUser(p.id)" />
|
||||||
|
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||||
|
<i class="fas fa-medal medal-icon"></i>
|
||||||
|
<span class="prize-member-winner-name">获奖者: </span>
|
||||||
|
<img v-for="w in lotteryWinners" :key="w.id" class="prize-member-avatar" :src="w.avatar" alt="avatar" @click="gotoUser(w.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CommentEditor @submit="postComment" :loading="isWaitingPostingComment" :disabled="!loggedIn"
|
<CommentEditor @submit="postComment" :loading="isWaitingPostingComment" :disabled="!loggedIn"
|
||||||
:show-login-overlay="!loggedIn" :parent-user-name="author.username" />
|
:show-login-overlay="!loggedIn" :parent-user-name="author.username" />
|
||||||
|
|
||||||
@@ -193,6 +226,7 @@ export default {
|
|||||||
document.title = defaultTitle
|
document.title = defaultTitle
|
||||||
if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription)
|
if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription)
|
||||||
window.removeEventListener('scroll', updateCurrentIndex)
|
window.removeEventListener('scroll', updateCurrentIndex)
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +236,45 @@ export default {
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||||
const isAuthor = computed(() => authState.username === author.value.username)
|
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 articleMenuItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
if (isAuthor.value || isAdmin.value) {
|
if (isAuthor.value || isAdmin.value) {
|
||||||
@@ -336,6 +409,8 @@ export default {
|
|||||||
status.value = data.status
|
status.value = data.status
|
||||||
pinnedAt.value = data.pinnedAt
|
pinnedAt.value = data.pinnedAt
|
||||||
postTime.value = TimeManager.format(data.createdAt)
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
|
lottery.value = data.lottery || null
|
||||||
|
if (lottery.value && lottery.value.endTime) startCountdown()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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 = () => {
|
const fetchCommentSorts = () => {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
||||||
@@ -639,10 +732,12 @@ export default {
|
|||||||
copyPostLink,
|
copyPostLink,
|
||||||
subscribePost,
|
subscribePost,
|
||||||
unsubscribePost,
|
unsubscribePost,
|
||||||
|
joinLottery,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
isWaitingFetchingPost,
|
isWaitingFetchingPost,
|
||||||
isWaitingPostingComment,
|
isWaitingPostingComment,
|
||||||
gotoProfile,
|
gotoProfile,
|
||||||
|
gotoUser,
|
||||||
subscribed,
|
subscribed,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
isAuthor,
|
isAuthor,
|
||||||
@@ -663,9 +758,14 @@ export default {
|
|||||||
pinnedAt,
|
pinnedAt,
|
||||||
commentSort,
|
commentSort,
|
||||||
fetchCommentSorts,
|
fetchCommentSorts,
|
||||||
isFetchingComments
|
isFetchingComments,
|
||||||
,
|
getMedalTitle,
|
||||||
getMedalTitle
|
lottery,
|
||||||
|
countdown,
|
||||||
|
lotteryParticipants,
|
||||||
|
lotteryWinners,
|
||||||
|
lotteryEnded,
|
||||||
|
hasJoined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1011,6 +1111,127 @@ export default {
|
|||||||
position: relative;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
|
|||||||
Reference in New Issue
Block a user