fix: 分类提案简化用户输入

This commit is contained in:
Tim
2025-10-22 20:33:24 +08:00
parent 23d8eafc08
commit 67efb64ccc
8 changed files with 96 additions and 239 deletions

View File

@@ -75,10 +75,7 @@ public class PostController {
req.getOptions(), req.getOptions(),
req.getMultiple(), req.getMultiple(),
req.getProposedName(), req.getProposedName(),
req.getProposedSlug(), req.getProposalDescription()
req.getProposalDescription(),
req.getApproveThreshold(),
req.getQuorum()
); );
draftService.deleteDraft(auth.getName()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());

View File

@@ -31,8 +31,5 @@ public class PostRequest {
// fields for category proposal posts // fields for category proposal posts
private String proposedName; private String proposedName;
private String proposedSlug;
private String proposalDescription; private String proposalDescription;
private Integer approveThreshold;
private Integer quorum;
} }

View File

@@ -7,59 +7,53 @@ import jakarta.persistence.Enumerated;
import jakarta.persistence.Index; import jakarta.persistence.Index;
import jakarta.persistence.PrimaryKeyJoinColumn; import jakarta.persistence.PrimaryKeyJoinColumn;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
import java.time.LocalDateTime;
/** /**
* A specialized post type used for proposing new categories. * A specialized post type used for proposing new categories.
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost. * It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
*/ */
@Entity @Entity
@Table(name = "category_proposal_posts", indexes = { @Table(
@Index(name = "idx_category_proposal_posts_status", columnList = "status"), name = "category_proposal_posts",
@Index(name = "idx_category_proposal_posts_slug", columnList = "proposed_slug", unique = true) indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
}) )
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id") @PrimaryKeyJoinColumn(name = "post_id")
public class CategoryProposalPost extends PollPost { public class CategoryProposalPost extends PollPost {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false) @Column(name = "status", nullable = false)
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING; private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
@Column(name = "proposed_name", nullable = false) @Column(name = "proposed_name", nullable = false, unique = true)
private String proposedName; private String proposedName;
@Column(name = "proposed_slug", nullable = false, unique = true) @Column(name = "description")
private String proposedSlug; private String description;
@Column(name = "description") // Approval threshold as percentage (0-100), default 60
private String description; @Column(name = "approve_threshold", nullable = false)
private int approveThreshold = 60;
// Approval threshold as percentage (0-100), default 60 // Minimum number of participants required to meet quorum
@Column(name = "approve_threshold", nullable = false) @Column(name = "quorum", nullable = false)
private int approveThreshold = 60; private int quorum = 10;
// Minimum number of participants required to meet quorum // Optional voting start time (end time inherited from PollPost)
@Column(name = "quorum", nullable = false) @Column(name = "start_at")
private int quorum = 10; private LocalDateTime startAt;
// Optional voting start time (end time inherited from PollPost) // Snapshot of poll results at finalization (e.g., JSON)
@Column(name = "start_at") @Column(name = "result_snapshot", columnDefinition = "TEXT")
private LocalDateTime startAt; private String resultSnapshot;
// Snapshot of poll results at finalization (e.g., JSON) // Reason when proposal is rejected
@Column(name = "result_snapshot", columnDefinition = "TEXT") @Column(name = "reject_reason")
private String resultSnapshot; private String rejectReason;
// Reason when proposal is rejected
@Column(name = "reject_reason")
private String rejectReason;
} }

View File

@@ -2,17 +2,18 @@ package com.openisle.repository;
import com.openisle.model.CategoryProposalPost; import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CategoryProposalStatus; import com.openisle.model.CategoryProposalStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> { public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> {
List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(LocalDateTime now, CategoryProposalStatus status); LocalDateTime now,
boolean existsByProposedSlug(String proposedSlug); CategoryProposalStatus status
);
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(
LocalDateTime now,
CategoryProposalStatus status
);
boolean existsByProposedNameIgnoreCase(String proposedName);
} }

View File

@@ -75,6 +75,11 @@ public class PostService {
private final SearchIndexEventPublisher searchIndexEventPublisher; private final SearchIndexEventPublisher searchIndexEventPublisher;
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl; private String websiteUrl;
@@ -253,10 +258,7 @@ public class PostService {
java.util.List<String> options, java.util.List<String> options,
Boolean multiple, Boolean multiple,
String proposedName, String proposedName,
String proposedSlug, String proposalDescription
String proposalDescription,
Integer approveThreshold,
Integer quorum
) { ) {
// 限制访问次数 // 限制访问次数
boolean limitResult = postRateLimit(username); boolean limitResult = postRateLimit(username);
@@ -307,35 +309,18 @@ public class PostService {
if (proposedName == null || proposedName.isBlank()) { if (proposedName == null || proposedName.isBlank()) {
throw new IllegalArgumentException("Proposed name required"); throw new IllegalArgumentException("Proposed name required");
} }
if (proposedSlug == null || proposedSlug.isBlank()) { String normalizedName = proposedName.trim();
throw new IllegalArgumentException("Proposed slug required"); if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
} }
if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { cp.setProposedName(normalizedName);
throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug);
}
cp.setProposedName(proposedName);
cp.setProposedSlug(proposedSlug);
cp.setDescription(proposalDescription); cp.setDescription(proposalDescription);
if (approveThreshold != null) { cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
if (approveThreshold < 0 || approveThreshold > 100) { cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); LocalDateTime now = LocalDateTime.now();
} cp.setStartAt(now);
cp.setApproveThreshold(approveThreshold); cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
} cp.setOptions(DEFAULT_PROPOSAL_OPTIONS);
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); cp.setMultiple(false);
post = cp; post = cp;
} else { } else {

View File

@@ -0,0 +1,8 @@
ALTER TABLE category_proposal_posts
DROP INDEX idx_category_proposal_posts_slug;
ALTER TABLE category_proposal_posts
DROP COLUMN proposed_slug;
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
ON category_proposal_posts (proposed_name);

View File

@@ -4,67 +4,19 @@
<span class="proposal-row-title">拟议分类名称</span> <span class="proposal-row-title">拟议分类名称</span>
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" /> <BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
</div> </div>
<div class="proposal-row">
<span class="proposal-row-title">拟议分类 Slug</span>
<BaseInput v-model="data.proposedSlug" placeholder="小写短横线分隔,如: tech-news" />
</div>
<div class="proposal-row"> <div class="proposal-row">
<span class="proposal-row-title">提案描述</span> <span class="proposal-row-title">提案描述</span>
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" /> <BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
</div> </div>
<div class="proposal-row two-col">
<div class="proposal-col">
<span class="proposal-row-title">通过阈值(%)</span>
<input
class="number-input"
type="number"
v-model.number="data.approveThreshold"
min="0"
max="100"
/>
</div>
<div class="proposal-col">
<span class="proposal-row-title">法定最小参与数</span>
<input class="number-input" type="number" v-model.number="data.quorum" min="0" />
</div>
</div>
<div class="proposal-row">
<span class="proposal-row-title">投票结束时间</span>
<client-only>
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
<div class="proposal-row">
<span class="proposal-row-title">投票选项</span>
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
const props = defineProps({ defineProps({
data: { type: Object, required: true }, data: { type: Object, required: true },
}) })
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
const addOption = () => {
props.data.options.push('')
}
const removeOption = (idx) => {
if (props.data.options.length > 2) {
props.data.options.splice(idx, 1)
}
}
</script> </script>
<style scoped> <style scoped>
@@ -75,55 +27,16 @@ const removeOption = (idx) => {
gap: 20px; gap: 20px;
margin-bottom: 200px; margin-bottom: 200px;
} }
.proposal-row-title { .proposal-row-title {
font-size: 16px; font-size: 16px;
color: var(--text-color); color: var(--text-color);
font-weight: bold; font-weight: bold;
margin-bottom: 10px; margin-bottom: 10px;
} }
.proposal-row { .proposal-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.proposal-col {
display: flex;
flex-direction: column;
}
.number-input {
max-width: 120px;
height: 30px;
border-radius: 5px;
border: 1px solid var(--border-color);
padding: 0 10px;
font-size: 16px;
color: var(--text-color);
background-color: var(--lottery-background-color);
}
.poll-option-item {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.remove-option-icon {
cursor: pointer;
}
.add-option {
color: var(--primary-color);
cursor: pointer;
width: fit-content;
margin-top: 5px;
}
.time-picker {
max-width: 200px;
height: 30px;
background-color: var(--lottery-background-color);
border-radius: 5px;
border: 1px solid var(--border-color);
}
</style> </style>

View File

@@ -80,12 +80,7 @@ const poll = reactive({
}) })
const proposal = reactive({ const proposal = reactive({
proposedName: '', proposedName: '',
proposedSlug: '',
proposalDescription: '', proposalDescription: '',
approveThreshold: 60,
quorum: 10,
endTime: null,
options: ['同意', '反对'],
}) })
const startTime = ref(null) const startTime = ref(null)
const isWaitingPosting = ref(false) const isWaitingPosting = ref(false)
@@ -135,12 +130,7 @@ const clearPost = async () => {
poll.endTime = null poll.endTime = null
poll.multiple = false poll.multiple = false
proposal.proposedName = '' proposal.proposedName = ''
proposal.proposedSlug = ''
proposal.proposalDescription = '' proposal.proposalDescription = ''
proposal.approveThreshold = 60
proposal.quorum = 10
proposal.endTime = null
proposal.options = ['同意', '反对']
// 删除草稿 // 删除草稿
const token = getToken() const token = getToken()
@@ -306,26 +296,6 @@ const submitPost = async () => {
toast.error('请填写拟议分类名称') toast.error('请填写拟议分类名称')
return 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 { try {
const token = getToken() const token = getToken()
@@ -347,51 +317,43 @@ const submitPost = async () => {
} }
prizeIconUrl = uploadData.data.url prizeIconUrl = uploadData.data.url
} }
const toUtcString = (value) => {
if (!value) return undefined
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
}
const payload = {
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
}
if (postType.value === 'LOTTERY') {
payload.prizeIcon = prizeIconUrl
payload.prizeName = lottery.prizeName
payload.prizeCount = lottery.prizeCount
payload.prizeDescription = lottery.prizeDescription
payload.pointCost = lottery.pointCost
payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined
payload.endTime = toUtcString(lottery.endTime)
} else if (postType.value === 'POLL') {
payload.options = poll.options
payload.multiple = poll.multiple
payload.endTime = toUtcString(poll.endTime)
} else if (postType.value === 'PROPOSAL') {
payload.proposedName = proposal.proposedName
payload.proposalDescription = proposal.proposalDescription
}
const res = await fetch(`${API_BASE_URL}/api/posts`, { const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
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,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? 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()
: postType.value === 'PROPOSAL'
? new Date(
new Date(proposal.endTime).getTime() + 8.02 * 60 * 60 * 1000,
).toISOString()
: undefined,
}),
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {