From 76962d6d1cfe3d61039b82d054913968d6f6abb5 Mon Sep 17 00:00:00 2001 From: sivdead <923396178@qq.com> Date: Thu, 25 Sep 2025 17:00:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=8C=85=E6=8B=AC?= =?UTF-8?q?=E6=8F=90=E6=A1=88=E8=A1=A8=E5=8D=95=E5=92=8C=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/open-isle.env.example | 1 + .../openisle/controller/PostController.java | 7 +- .../java/com/openisle/dto/PostRequest.java | 7 + .../openisle/model/CategoryProposalPost.java | 65 ++++++++ .../model/CategoryProposalStatus.java | 10 ++ .../java/com/openisle/model/PostType.java | 1 + .../CategoryProposalPostRepository.java | 18 +++ .../com/openisle/service/PostService.java | 149 ++++++++++++++++-- .../src/main/resources/application.properties | 1 + .../V6__add_category_proposal_posts.sql | 25 +++ .../controller/PostControllerTest.java | 9 ++ .../com/openisle/service/PostServiceTest.java | 15 ++ frontend_nuxt/components/PostTypeSelect.vue | 1 + frontend_nuxt/components/ProposalForm.vue | 133 ++++++++++++++++ frontend_nuxt/pages/new-post.vue | 62 +++++++- 15 files changed, 485 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/com/openisle/model/CategoryProposalPost.java create mode 100644 backend/src/main/java/com/openisle/model/CategoryProposalStatus.java create mode 100644 backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java create mode 100644 backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql create mode 100644 frontend_nuxt/components/ProposalForm.vue diff --git a/backend/open-isle.env.example b/backend/open-isle.env.example index a62ac877f..a9b0ccc8b 100644 --- a/backend/open-isle.env.example +++ b/backend/open-isle.env.example @@ -16,6 +16,7 @@ JWT_EXPIRATION=2592000000 # === Redis === REDIS_HOST= REDIS_PORT= +REDIS_PASS= # === Resend === RESEND_API_KEY=<你的resend-api-key> diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index 0a039975e..8fb6579a9 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -73,7 +73,12 @@ public class PostController { req.getStartTime(), req.getEndTime(), req.getOptions(), - req.getMultiple() + req.getMultiple(), + req.getProposedName(), + req.getProposedSlug(), + req.getProposalDescription(), + req.getApproveThreshold(), + req.getQuorum() ); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 0419804ea..3a7dad5f8 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -28,4 +28,11 @@ public class PostRequest { // fields for poll posts private List options; private Boolean multiple; + + // fields for category proposal posts + private String proposedName; + private String proposedSlug; + private String proposalDescription; + private Integer approveThreshold; + private Integer quorum; } diff --git a/backend/src/main/java/com/openisle/model/CategoryProposalPost.java b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java new file mode 100644 index 000000000..dde9dfddf --- /dev/null +++ b/backend/src/main/java/com/openisle/model/CategoryProposalPost.java @@ -0,0 +1,65 @@ +package com.openisle.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.PrimaryKeyJoinColumn; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * A specialized post type used for proposing new categories. + * It reuses poll mechanics (participants, votes, endTime) by extending PollPost. + */ +@Entity +@Table(name = "category_proposal_posts", indexes = { + @Index(name = "idx_category_proposal_posts_status", columnList = "status"), + @Index(name = "idx_category_proposal_posts_slug", columnList = "proposed_slug", unique = true) +}) +@Getter +@Setter +@NoArgsConstructor +@PrimaryKeyJoinColumn(name = "post_id") +public class CategoryProposalPost extends PollPost { + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING; + + @Column(name = "proposed_name", nullable = false) + private String proposedName; + + @Column(name = "proposed_slug", nullable = false, unique = true) + private String proposedSlug; + + @Column(name = "description") + private String description; + + // Approval threshold as percentage (0-100), default 60 + @Column(name = "approve_threshold", nullable = false) + private int approveThreshold = 60; + + // Minimum number of participants required to meet quorum + @Column(name = "quorum", nullable = false) + private int quorum = 10; + + // Optional voting start time (end time inherited from PollPost) + @Column(name = "start_at") + private LocalDateTime startAt; + + // Snapshot of poll results at finalization (e.g., JSON) + @Column(name = "result_snapshot", columnDefinition = "TEXT") + private String resultSnapshot; + + // Reason when proposal is rejected + @Column(name = "reject_reason") + private String rejectReason; +} + + diff --git a/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java b/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java new file mode 100644 index 000000000..81c2649c3 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/CategoryProposalStatus.java @@ -0,0 +1,10 @@ +package com.openisle.model; + +public enum CategoryProposalStatus { + PENDING, + APPROVED, + REJECTED +} + + + diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java index 7e675dafc..75509566a 100644 --- a/backend/src/main/java/com/openisle/model/PostType.java +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -4,4 +4,5 @@ public enum PostType { NORMAL, LOTTERY, POLL, + PROPOSAL } diff --git a/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java new file mode 100644 index 000000000..a403e44b4 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/CategoryProposalPostRepository.java @@ -0,0 +1,18 @@ +package com.openisle.repository; + +import com.openisle.model.CategoryProposalPost; +import com.openisle.model.CategoryProposalStatus; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface CategoryProposalPostRepository extends JpaRepository { + List findByEndTimeAfterAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); + List findByEndTimeBeforeAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); + boolean existsByProposedSlug(String proposedSlug); +} + + + diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 029c82882..0af3758f6 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,6 +1,30 @@ package com.openisle.service; import com.openisle.config.CachingConfig; +import com.openisle.exception.NotFoundException; +import com.openisle.exception.RateLimitException; +import com.openisle.model.*; +import com.openisle.repository.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.context.ApplicationContext; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; import com.openisle.exception.RateLimitException; import com.openisle.mapper.PostMapper; import com.openisle.model.*; @@ -26,22 +50,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; import java.util.stream.Collectors; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.context.ApplicationContext; -import org.springframework.context.event.EventListener; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.CollectionUtils; @Slf4j @Service @@ -53,6 +61,7 @@ public class PostService { private final TagRepository tagRepository; private final LotteryPostRepository lotteryPostRepository; private final PollPostRepository pollPostRepository; + private final CategoryProposalPostRepository categoryProposalPostRepository; private final PollVoteRepository pollVoteRepository; private PublishMode publishMode; private final NotificationService notificationService; @@ -86,6 +95,7 @@ public class PostService { TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, PollPostRepository pollPostRepository, + CategoryProposalPostRepository categoryProposalPostRepository, PollVoteRepository pollVoteRepository, NotificationService notificationService, SubscriptionService subscriptionService, @@ -155,6 +165,19 @@ public class PostService { for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); } + for (CategoryProposalPost cp : categoryProposalPostRepository + .findByEndTimeAfterAndProposalStatus(now, CategoryProposalStatus.PENDING)) { + if (cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + scheduledFinalizations.put(cp.getId(), future); + } + } + for (CategoryProposalPost cp : categoryProposalPostRepository + .findByEndTimeBeforeAndProposalStatus(now, CategoryProposalStatus.PENDING)) { + applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); + } } public PublishMode getPublishMode() { @@ -227,7 +250,12 @@ public class PostService { LocalDateTime startTime, LocalDateTime endTime, java.util.List options, - Boolean multiple + Boolean multiple, + String proposedName, + String proposedSlug, + String proposalDescription, + Integer approveThreshold, + Integer quorum ) { // 限制访问次数 boolean limitResult = postRateLimit(username); @@ -273,6 +301,42 @@ public class PostService { pp.setEndTime(endTime); pp.setMultiple(multiple != null && multiple); post = pp; + } else if (actualType == PostType.PROPOSAL) { + CategoryProposalPost cp = new CategoryProposalPost(); + if (proposedName == null || proposedName.isBlank()) { + throw new IllegalArgumentException("Proposed name required"); + } + if (proposedSlug == null || proposedSlug.isBlank()) { + throw new IllegalArgumentException("Proposed slug required"); + } + if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { + throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); + } + cp.setProposedName(proposedName); + cp.setProposedSlug(proposedSlug); + cp.setDescription(proposalDescription); + if (approveThreshold != null) { + if (approveThreshold < 0 || approveThreshold > 100) { + throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); + } + cp.setApproveThreshold(approveThreshold); + } + if (quorum != null) { + if (quorum < 0) { + throw new IllegalArgumentException("quorum must be >= 0"); + } + cp.setQuorum(quorum); + } + cp.setStartAt(startTime); + cp.setEndTime(endTime); + // default yes/no options if not provided + if (options == null || options.size() < 2) { + cp.setOptions(List.of("同意", "反对")); + } else { + cp.setOptions(options); + } + cp.setMultiple(false); + post = cp; } else { post = new Post(); } @@ -285,6 +349,8 @@ public class PostService { post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); + }else if (post instanceof CategoryProposalPost categoryProposalPost) { + post = categoryProposalPostRepository.save(categoryProposalPost); } else if (post instanceof PollPost) { post = pollPostRepository.save((PollPost) post); } else { @@ -339,6 +405,11 @@ public class PostService { java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) ); scheduledFinalizations.put(lp.getId(), future); + } else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); + scheduledFinalizations.put(cp.getId(), future); } else if (post instanceof PollPost pp && pp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), @@ -349,6 +420,50 @@ public class PostService { return post; } + @CacheEvict( + value = CachingConfig.POST_CACHE_NAME, allEntries = true + ) + @Transactional + public void finalizeProposal(Long postId) { + scheduledFinalizations.remove(postId); + categoryProposalPostRepository.findById(postId).ifPresent(cp -> { + if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { + return; + } + int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; + int approveVotes = 0; + if (cp.getVotes() != null) { + approveVotes = cp.getVotes().getOrDefault(0, 0); + } + boolean quorumMet = totalParticipants >= cp.getQuorum(); + int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; + boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); + if (quorumMet && thresholdMet) { + cp.setProposalStatus(CategoryProposalStatus.APPROVED); + } else { + cp.setProposalStatus(CategoryProposalStatus.REJECTED); + String reason; + if (!quorumMet && !thresholdMet) { + reason = "未达到法定人数且赞成率不足"; + } else if (!quorumMet) { + reason = "未达到法定人数"; + } else { + reason = "赞成率不足"; + } + cp.setRejectReason(reason); + } + cp.setResultSnapshot("approveVotes=" + approveVotes + ", totalParticipants=" + totalParticipants + ", approvePercent=" + approvePercent); + categoryProposalPostRepository.save(cp); + if (cp.getAuthor() != null) { + notificationService.createNotification(cp.getAuthor(), NotificationType.POLL_RESULT_OWNER, cp, null, null, null, null, null); + } + for (User participant : cp.getParticipants()) { + notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, cp, null, null, null, null, null); + } + postChangeLogService.recordVoteResult(cp); + }); + } + /** * 限制发帖频率 * @param username diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 00ccc0302..1fef7f09b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update spring.data.redis.host=${REDIS_HOST:localhost} spring.data.redis.port=${REDIS_PORT:6379} spring.data.redis.database=${REDIS_DATABASE:0} +spring.data.redis.password=${REDIS_PASS: null} # for jwt app.jwt.secret=${JWT_SECRET:jwt_sec} diff --git a/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql b/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql new file mode 100644 index 000000000..1ac39337b --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_category_proposal_posts.sql @@ -0,0 +1,25 @@ +-- Create table for category proposal posts (subclass of poll_posts) +CREATE TABLE IF NOT EXISTS category_proposal_posts ( + post_id BIGINT NOT NULL, + status VARCHAR(50) NOT NULL, + proposed_name VARCHAR(255) NOT NULL, + proposed_slug VARCHAR(255) NOT NULL, + description VARCHAR(255), + approve_threshold INT NOT NULL DEFAULT 60, + quorum INT NOT NULL DEFAULT 10, + start_at DATETIME(6) NULL, + result_snapshot LONGTEXT NULL, + reject_reason VARCHAR(255), + PRIMARY KEY (post_id), + CONSTRAINT fk_category_proposal_posts_parent + FOREIGN KEY (post_id) REFERENCES poll_posts (post_id) +); + +CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status + ON category_proposal_posts (status); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug + ON category_proposal_posts (proposed_slug); + + + diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 3bf386ff4..12180eecc 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -117,6 +117,11 @@ class PostControllerTest { isNull(), isNull(), isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), isNull() ) ).thenReturn(post); @@ -266,6 +271,10 @@ class PostControllerTest { any(), any(), any(), + any(), + any(), + any(), + any(), any() ); } diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index 6d9a220b6..71f3ebc45 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -25,6 +25,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -50,6 +51,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -101,6 +103,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -126,6 +129,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -190,6 +194,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -215,6 +220,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -253,6 +259,11 @@ class PostServiceTest { null, null, null, + null, + null, + null, + null, + null, null ) ); @@ -266,6 +277,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -291,6 +303,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, @@ -358,6 +371,7 @@ class PostServiceTest { TagRepository tagRepo = mock(TagRepository.class); LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class); PollPostRepository pollPostRepo = mock(PollPostRepository.class); + CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class); PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class); NotificationService notifService = mock(NotificationService.class); SubscriptionService subService = mock(SubscriptionService.class); @@ -383,6 +397,7 @@ class PostServiceTest { tagRepo, lotteryRepo, pollPostRepo, + proposalRepo, pollVoteRepo, notifService, subService, diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue index 5fa16eab7..cfb605922 100644 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -34,6 +34,7 @@ export default { { id: 'NORMAL', name: '普通帖子', icon: 'file-text' }, { id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' }, { id: 'POLL', name: '投票帖子', icon: 'ranking-list' }, + { id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' }, ] } diff --git a/frontend_nuxt/components/ProposalForm.vue b/frontend_nuxt/components/ProposalForm.vue new file mode 100644 index 000000000..382b70a5e --- /dev/null +++ b/frontend_nuxt/components/ProposalForm.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index bd13de3f5..1128c0991 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -37,6 +37,7 @@ + @@ -50,6 +51,7 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue' import TagSelect from '~/components/TagSelect.vue' import LotteryForm from '~/components/LotteryForm.vue' import PollForm from '~/components/PollForm.vue' +import ProposalForm from '~/components/ProposalForm.vue' import { toast } from '~/main' import { authState, getToken } from '~/utils/auth' const config = useRuntimeConfig() @@ -76,6 +78,15 @@ const poll = reactive({ endTime: null, multiple: false, }) +const proposal = reactive({ + proposedName: '', + proposedSlug: '', + proposalDescription: '', + approveThreshold: 60, + quorum: 10, + endTime: null, + options: ['同意', '反对'], +}) const startTime = ref(null) const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -123,6 +134,13 @@ const clearPost = async () => { poll.options = ['', ''] poll.endTime = null poll.multiple = false + proposal.proposedName = '' + proposal.proposedSlug = '' + proposal.proposalDescription = '' + proposal.approveThreshold = 60 + proposal.quorum = 10 + proposal.endTime = null + proposal.options = ['同意', '反对'] // 删除草稿 const token = getToken() @@ -283,6 +301,32 @@ const submitPost = async () => { return } } + if (postType.value === 'PROPOSAL') { + if (!proposal.proposedName.trim()) { + toast.error('请填写拟议分类名称') + return + } + if (!proposal.proposedSlug.trim()) { + toast.error('请填写拟议分类 Slug') + return + } + if (proposal.approveThreshold < 0 || proposal.approveThreshold > 100) { + toast.error('通过阈值需在0到100之间') + return + } + if (proposal.quorum < 0) { + toast.error('最小参与数需大于或等于0') + return + } + if (proposal.options.length < 2 || proposal.options.some((o) => !o.trim())) { + toast.error('请填写至少两个投票选项') + return + } + if (!proposal.endTime) { + toast.error('请选择投票结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -321,6 +365,18 @@ const submitPost = async () => { prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined, options: postType.value === 'POLL' ? poll.options : undefined, multiple: postType.value === 'POLL' ? poll.multiple : undefined, + proposedName: postType.value === 'PROPOSAL' ? proposal.proposedName : undefined, + proposedSlug: postType.value === 'PROPOSAL' ? proposal.proposedSlug : undefined, + proposalDescription: + postType.value === 'PROPOSAL' ? proposal.proposalDescription : undefined, + approveThreshold: postType.value === 'PROPOSAL' ? proposal.approveThreshold : undefined, + quorum: postType.value === 'PROPOSAL' ? proposal.quorum : undefined, + options: + postType.value === 'POLL' + ? poll.options + : postType.value === 'PROPOSAL' + ? proposal.options + : undefined, startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined, @@ -330,7 +386,11 @@ const submitPost = async () => { ? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : postType.value === 'POLL' ? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString() - : undefined, + : postType.value === 'PROPOSAL' + ? new Date( + new Date(proposal.endTime).getTime() + 8.02 * 60 * 60 * 1000, + ).toISOString() + : undefined, }), }) const data = await res.json()