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:
Tim
2025-08-11 09:56:45 +08:00
committed by GitHub
17 changed files with 736 additions and 17 deletions
@@ -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>
+2 -2
View File
@@ -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'
+23
View File
@@ -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",
+3 -1
View File
@@ -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"
} }
} }
+217 -4
View File
@@ -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);
+224 -3
View File
@@ -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);