Merge pull request #1031 from sivdead/feat/category_proposal

feat: 添加分类提案功能,包括提案表单和相关后端逻辑
This commit is contained in:
Tim
2025-10-23 17:03:48 +08:00
committed by GitHub
28 changed files with 787 additions and 108 deletions

View File

@@ -1,5 +1,6 @@
- [前置工作](#前置工作)
- [前端极速调试Docker 全量环境)](#前端极速调试docker-全量环境)
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
@@ -39,13 +40,6 @@ cd OpenIsle
```
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
2. 启动 Dev Profile
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev build
```
```shell
docker compose \
-f docker/docker-compose.yaml \
@@ -81,6 +75,41 @@ cd OpenIsle
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
<a id="dev-dev_local_backend-guide"></a>
### 🧭 dev 与 dev_local_backend 巡航指南
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile
```bash
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev_local_backend up -d
```
> [!TIP]
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
| --- | --- | --- | --- |
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
```bash
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
# 或者
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
```
常见小贴士:
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
## 启动后端服务
启动后端服务有多种方式,选择一种即可。
@@ -110,6 +139,17 @@ IDEA 打开 `backend/` 文件夹。
LOG_LEVEL=DEBUG
```
> [!WARNING]
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`
>
> ```ini
> MYSQL_HOST=localhost
> REDIS_HOST=localhost
> RABBITMQ_HOST=localhost
> ```
>
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`IDEA 将尝试解析容器网络内的别名而导致连接失败。
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
![配置数据库](assets/contributing/backend_img_5.png)

View File

@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
# === Redis ===
REDIS_HOST=<Redis 地址>
REDIS_PORT=<Redis 端口>
REDIS_PASS=<Redis 密码>
# === Resend ===
RESEND_API_KEY=<你的resend-api-key>

View File

@@ -73,7 +73,9 @@ public class PostController {
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple()
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());

View File

@@ -28,4 +28,8 @@ public class PostRequest {
// fields for poll posts
private List<String> options;
private Boolean multiple;
// fields for category proposal posts
private String proposedName;
private String proposalDescription;
}

View File

@@ -0,0 +1,20 @@
package com.openisle.dto;
import com.openisle.model.CategoryProposalStatus;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ProposalDto extends PollDto {
private CategoryProposalStatus proposalStatus;
private String proposedName;
private String description;
private int approveThreshold;
private int quorum;
private LocalDateTime startAt;
private String resultSnapshot;
private String rejectReason;
}

View File

@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
import com.openisle.dto.PollDto;
import com.openisle.dto.PostDetailDto;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.ProposalDto;
import com.openisle.dto.ReactionDto;
import com.openisle.model.CategoryProposalPost;
import com.openisle.model.CommentSort;
import com.openisle.model.LotteryPost;
import com.openisle.model.PollPost;
@@ -113,26 +115,40 @@ public class PostMapper {
dto.setLottery(l);
}
if (post instanceof PollPost pp) {
PollDto p = new PollDto();
p.setOptions(pp.getOptions());
p.setVotes(pp.getVotes());
p.setEndTime(pp.getEndTime());
p.setParticipants(
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pp.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
if (post instanceof CategoryProposalPost cp) {
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
proposalDto.setProposalStatus(cp.getProposalStatus());
proposalDto.setProposedName(cp.getProposedName());
proposalDto.setDescription(cp.getDescription());
proposalDto.setApproveThreshold(cp.getApproveThreshold());
proposalDto.setQuorum(cp.getQuorum());
proposalDto.setStartAt(cp.getStartAt());
proposalDto.setResultSnapshot(cp.getResultSnapshot());
proposalDto.setRejectReason(cp.getRejectReason());
dto.setPoll(proposalDto);
} else if (post instanceof PollPost pp) {
dto.setPoll(buildPollDto(pp, new PollDto()));
}
}
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
target.setOptions(pollPost.getOptions());
target.setVotes(pollPost.getVotes());
target.setEndTime(pollPost.getEndTime());
target.setParticipants(
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
);
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
.findByPostId(pollPost.getId())
.stream()
.collect(
Collectors.groupingBy(
PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
)
);
target.setOptionParticipants(optionParticipants);
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
return target;
}
}

View File

@@ -0,0 +1,59 @@
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 java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 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") }
)
@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, unique = true)
private String proposedName;
@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;
}

View File

@@ -0,0 +1,10 @@
package com.openisle.model;
public enum CategoryProposalStatus {
PENDING,
APPROVED,
REJECTED
}

View File

@@ -46,6 +46,10 @@ public enum NotificationType {
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your category proposal has concluded */
CATEGORY_PROPOSAL_RESULT_OWNER,
/** A category proposal you participated in has concluded */
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** Someone donated to your post */

View File

@@ -4,4 +4,5 @@ public enum PostType {
NORMAL,
LOTTERY,
POLL,
PROPOSAL
}

View File

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

View File

@@ -2,8 +2,8 @@ package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.exception.RateLimitException;
import com.openisle.mapper.PostMapper;
import com.openisle.model.*;
import com.openisle.repository.CategoryProposalPostRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.LotteryPostRepository;
@@ -21,7 +21,6 @@ import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -32,7 +31,6 @@ 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;
@@ -54,6 +52,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;
@@ -71,11 +70,17 @@ public class PostService {
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final CategoryService categoryService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
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}")
private String websiteUrl;
@@ -89,6 +94,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
CategoryProposalPostRepository categoryProposalPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@@ -107,7 +113,8 @@ public class PostService {
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate,
SearchIndexEventPublisher searchIndexEventPublisher
SearchIndexEventPublisher searchIndexEventPublisher,
CategoryService categoryService
) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -115,6 +122,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.categoryProposalPostRepository = categoryProposalPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
@@ -135,6 +143,7 @@ public class PostService {
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
@@ -160,6 +169,24 @@ 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() {
@@ -232,10 +259,12 @@ public class PostService {
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple
Boolean multiple,
String proposedName,
String proposalDescription
) {
// 限制访问次数
boolean limitResult = postRateLimit(username);
boolean limitResult = isPostLimitReached(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
@@ -278,6 +307,25 @@ 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");
}
String normalizedName = proposedName.trim();
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
}
cp.setProposedName(normalizedName);
cp.setDescription(proposalDescription);
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
LocalDateTime now = LocalDateTime.now();
cp.setStartAt(now);
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
cp.setMultiple(false);
post = cp;
} else {
post = new Post();
}
@@ -290,6 +338,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 {
@@ -344,6 +394,12 @@ 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()),
@@ -354,24 +410,110 @@ public class PostService {
if (post.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(post);
}
markPostLimit(author.getUsername());
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();
boolean approved = false;
String rejectReason = null;
if (quorumMet && thresholdMet) {
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
approved = true;
} else {
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
String reason;
if (!quorumMet && !thresholdMet) {
reason = "未达到法定人数且赞成率不足";
} else if (!quorumMet) {
reason = "未达到法定人数";
} else {
reason = "赞成率不足";
}
cp.setRejectReason(reason);
rejectReason = reason;
}
cp.setResultSnapshot(
"approveVotes=" +
approveVotes +
", totalParticipants=" +
totalParticipants +
", approvePercent=" +
approvePercent
);
categoryProposalPostRepository.save(cp);
if (approved) {
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
}
if (cp.getAuthor() != null) {
notificationService.createNotification(
cp.getAuthor(),
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
for (User participant : cp.getParticipants()) {
if (
cp.getAuthor() != null &&
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
) {
continue;
}
notificationService.createNotification(
participant,
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
postChangeLogService.recordVoteResult(cp);
});
}
/**
* 限制发帖频率
* 检查用户是否达到发帖限制
* @param username
* @return
* @return true - 允许发帖false - 已达限制
*/
private boolean postRateLimit(String username) {
private boolean isPostLimitReached(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
String result = (String) redisTemplate.opsForValue().get(key);
//最近没有创建过文章
if (StringUtils.isEmpty(result)) {
// 限制频率为5分钟
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
return true;
}
return false;
return StringUtils.isEmpty(result);
}
/**
* 标记用户发帖触发limit计时
* @param username
*/
private void markPostLimit(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@@ -450,6 +592,9 @@ public class PostService {
pollPostRepository
.findById(postId)
.ifPresent(pp -> {
if (pp instanceof CategoryProposalPost) {
return;
}
if (pp.isResultAnnounced()) {
return;
}

View File

@@ -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}

View File

@@ -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);

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

@@ -76,6 +76,15 @@ class PostControllerTest {
@MockBean
private MedalService medalService;
@MockBean
private CategoryService categoryService;
@MockBean
private TagService tagService;
@MockBean
private PointService pointService;
@MockBean
private com.openisle.repository.PollVoteRepository pollVoteRepository;
@@ -117,6 +126,11 @@ class PostControllerTest {
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull()
)
).thenReturn(post);
@@ -266,6 +280,11 @@ class PostControllerTest {
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()
);
}

View File

@@ -26,6 +26,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);
@@ -52,6 +53,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -104,6 +106,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);
@@ -130,6 +133,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -195,6 +199,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);
@@ -221,6 +226,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -260,6 +266,11 @@ class PostServiceTest {
null,
null,
null,
null,
null,
null,
null,
null,
null
)
);
@@ -273,6 +284,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);
@@ -299,6 +311,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -367,6 +380,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);
@@ -393,6 +407,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,

View File

@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
app.snippet-length=${SNIPPET_LENGTH:200}

View File

@@ -25,6 +25,10 @@ services:
timeout: 3s
retries: 30
start_period: 20s
profiles:
- dev
- dev_local_backend
- prod
# OpenSearch Service
opensearch:
@@ -61,6 +65,9 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
@@ -75,6 +82,10 @@ services:
restart: unless-stopped
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
rabbitmq:
image: rabbitmq:3.13-management
@@ -98,6 +109,10 @@ services:
start_period: 30s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
redis:
image: redis:7
@@ -111,6 +126,10 @@ services:
- redis-data:/data
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
springboot:
@@ -155,6 +174,9 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- prod
websocket-service:
image: maven:3.9-eclipse-temurin-17
@@ -186,6 +208,10 @@ services:
start_period: 60s
networks:
- openisle-network
profiles:
- dev
- dev_local_backend
- prod
frontend_dev:
image: node:20
@@ -208,6 +234,28 @@ services:
- openisle-network
profiles:
- dev
frontend_dev_local_backend:
image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
working_dir: /app
env_file:
- ${ENV_FILE:-../.env}
command: sh -c "npm install && npm run dev"
volumes:
- ../frontend_nuxt:/app
- frontend-node-modules:/app/node_modules
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
websocket-service:
condition: service_healthy
networks:
- openisle-network
profiles:
- dev_local_backend
extra_hosts:
- "host.docker.internal:host-gateway"
frontend_service:
build:
@@ -226,13 +274,13 @@ services:
websocket-service:
condition: service_healthy
restart: unless-stopped
profiles: ["staging", "prod"]
profiles:
- prod
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
loopback_8080:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
command:
- -d
- -d
@@ -243,13 +291,37 @@ services:
springboot:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
loopback_8080_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
- TCP4:host.docker.internal:8080
network_mode: "service:frontend_dev_local_backend"
depends_on:
frontend_dev_local_backend:
condition: service_started
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
loopback_8082:
image: alpine/socat
@@ -265,13 +337,37 @@ services:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev
loopback_8082_host:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082WS 纯 TCP 可直接过)
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
- TCP4:websocket-service:8082
depends_on:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev_local_backend"
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
profiles:
- dev_local_backend
networks:
openisle-network:

View File

@@ -4,11 +4,7 @@
<span class="poll-row-title">投票选项</span>
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
<i
v-if="data.options.length > 2"
class="fa-solid fa-xmark remove-option-icon"
@click="removeOption(idx)"
></i>
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
</div>
<div class="add-option" @click="addOption">添加选项</div>
</div>

View File

@@ -2,6 +2,30 @@
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div class="poll-title-section">
<div class="poll-title-section-row">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else-if="isProposal">
拟议分类{{ poll.proposedName }}
<ToolTip
content="🗳️ 提案提交后将开放3天投票需达到至少60%的赞成率并满10人参与方可通过。"
placement="bottom"
v-if="isProposal"
>
<info-icon class="info-icon" />
</ToolTip>
</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<div class="poll-title-section-row">
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
</div>
</div>
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
@@ -29,16 +53,6 @@
</div>
</div>
<div v-else>
<div class="poll-title-section">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
@@ -103,11 +117,6 @@
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
<div v-else class="poll-option-hint">
<div>您已投票等待结束查看结果</div>
<div class="poll-left-time">
<stopwatch class="poll-left-time-icon" />
<div class="poll-left-time-title">离结束</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
</div>
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const isProposal = computed(() =>
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
padding: 10px;
}
.proposal-info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background-color: var(--background-color);
color: var(--text-color);
}
.proposal-name {
font-weight: 600;
font-size: 16px;
}
.proposal-status {
font-size: 14px;
opacity: 0.8;
}
.proposal-description {
font-size: 16px;
margin-top: 10px;
line-height: 1.5;
white-space: pre-wrap;
opacity: 0.8;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
}
.poll-title-section {
display: flex;
gap: 30px;
flex-direction: row;
margin-bottom: 20px;
}
.poll-title-section-row {
display: flex;
align-items: center;
flex-direction: row;
gap: 30px;
}
.info-icon {
margin-right: 20px;
}
.poll-option-title {
font-size: 18px;
font-weight: bold;

View File

@@ -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' },
]
}

View File

@@ -0,0 +1,77 @@
<template>
<div class="proposal-section">
<div class="proposal-row">
<span class="proposal-row-title rule">
<info-icon class="proposal-description-title-icon" />提案规则说明</span
>
<div class="proposal-description-content">
<p>📛 拟议分类名称需保持唯一请勿与现有分类或正在提案中的名称重复</p>
<p>📝 请在下方详细说明提案目的预期价值及补充材料方便大家快速理解</p>
<p>🗳 提案提交后将开放 3 天投票需达到至少 60% 的赞成率并满 10 人参与方可通过</p>
<p>🤝 讨论请遵循社区守则保持礼貌和善欢迎附上相关案例或参考链接</p>
</div>
</div>
<div class="proposal-row">
<span class="proposal-row-title">拟议分类名称</span>
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
</div>
<div class="proposal-row">
<span class="proposal-row-title">提案描述</span>
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
</div>
</div>
</template>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
defineProps({
data: { type: Object, required: true },
})
</script>
<style scoped>
.proposal-section {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 200px;
}
.proposal-row-title {
font-size: 16px;
color: var(--text-color);
font-weight: bold;
margin-bottom: 10px;
}
.proposal-row-title.rule {
margin-bottom: 0;
}
.proposal-row {
display: flex;
flex-direction: column;
}
.proposal-activity {
margin-top: 20px;
padding: 20px;
}
.proposal-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.proposal-description-title-icon {
margin-right: 5px;
}
.proposal-description-content {
font-size: 12px;
opacity: 0.8;
}
</style>

View File

@@ -1,10 +1,5 @@
<template>
<div class="home-page">
<!-- <div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div> -->
<div class="topic-container">
<div class="topic-item-container">
<div
@@ -72,6 +67,7 @@
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
</NuxtLink>
@@ -116,7 +112,7 @@
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<InfiniteLoadMore
v-if="articles.length > 0"
:key="ioKey"
@@ -572,6 +568,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.pinned-icon,
.lottery-icon,
.featured-icon,
.proposal-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);

View File

@@ -75,7 +75,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
<span
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
></span>
</NuxtLink>
</span>
回复了
@@ -85,7 +87,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -115,7 +119,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -162,7 +168,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
<span
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
></span>
</NuxtLink>
</span>
进行了表态
@@ -251,6 +259,38 @@
已出结果
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
<NotificationContainer :item="item" :markRead="markRead">
你的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
<NotificationContainer :item="item" :markRead="markRead">
你参与的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -287,7 +327,9 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
<span
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
></span>
</NuxtLink>
回复了
<NuxtLink
@@ -295,7 +337,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -775,6 +817,10 @@ const formatType = (t) => {
return '发布的投票结果已公布'
case 'POLL_RESULT_PARTICIPANT':
return '参与的投票结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
return '分类提案结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
return '参与的分类提案结果已公布'
default:
return t
}

View File

@@ -37,6 +37,7 @@
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
<PollForm v-if="postType === 'POLL'" :data="poll" />
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
</div>
</div>
</template>
@@ -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,10 @@ const poll = reactive({
endTime: null,
multiple: false,
})
const proposal = reactive({
proposedName: '',
proposalDescription: '',
})
const startTime = ref(null)
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
@@ -123,6 +129,8 @@ const clearPost = async () => {
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
proposal.proposedName = ''
proposal.proposalDescription = ''
// 删除草稿
const token = getToken()
@@ -283,6 +291,12 @@ const submitPost = async () => {
return
}
}
if (postType.value === 'PROPOSAL') {
if (!proposal.proposedName.trim()) {
toast.error('请填写拟议分类名称')
return
}
}
try {
const token = getToken()
await ensureTags(token)
@@ -303,35 +317,43 @@ const submitPost = async () => {
}
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`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
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,
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()
: undefined,
}),
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok) {

View File

@@ -81,6 +81,7 @@ import {
CheckOne,
Share,
Financing,
Hands,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -165,4 +166,5 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('CheckOne', CheckOne)
nuxtApp.vueApp.component('Share', Share)
nuxtApp.vueApp.component('Financing', Financing)
nuxtApp.vueApp.component('Hands', Hands)
})

View File

@@ -28,6 +28,8 @@ const iconMap = {
POLL_VOTE: 'ChartHistogram',
POLL_RESULT_OWNER: 'RankingList',
POLL_RESULT_PARTICIPANT: 'ChartLine',
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
MENTION: 'HashtagKey',
POST_DELETED: 'ClearIcon',
POST_FEATURED: 'Star',
@@ -254,7 +256,9 @@ function createFetchNotifications() {
} else if (
n.type === 'POLL_VOTE' ||
n.type === 'POLL_RESULT_OWNER' ||
n.type === 'POLL_RESULT_PARTICIPANT'
n.type === 'POLL_RESULT_PARTICIPANT' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
) {
arr.push({
...n,