mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-05-21 09:57:28 +08:00
Merge pull request #1031 from sivdead/feat/category_proposal
feat: 添加分类提案功能,包括提案表单和相关后端逻辑
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
- [前置工作](#前置工作)
|
- [前置工作](#前置工作)
|
||||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||||
|
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
||||||
- [启动后端服务](#启动后端服务)
|
- [启动后端服务](#启动后端服务)
|
||||||
- [本地 IDEA](#本地-idea)
|
- [本地 IDEA](#本地-idea)
|
||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
@@ -39,13 +40,6 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||||
2. 启动 Dev Profile:
|
2. 启动 Dev Profile:
|
||||||
```shell
|
|
||||||
docker compose \
|
|
||||||
-f docker/docker-compose.yaml \
|
|
||||||
--env-file .env \
|
|
||||||
--profile dev build
|
|
||||||
```
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker/docker-compose.yaml \
|
-f docker/docker-compose.yaml \
|
||||||
@@ -81,6 +75,41 @@ cd OpenIsle
|
|||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 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
|
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 追踪,通常不推荐。
|
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
|
|||||||
# === Redis ===
|
# === Redis ===
|
||||||
REDIS_HOST=<Redis 地址>
|
REDIS_HOST=<Redis 地址>
|
||||||
REDIS_PORT=<Redis 端口>
|
REDIS_PORT=<Redis 端口>
|
||||||
|
REDIS_PASS=<Redis 密码>
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ public class PostController {
|
|||||||
req.getStartTime(),
|
req.getStartTime(),
|
||||||
req.getEndTime(),
|
req.getEndTime(),
|
||||||
req.getOptions(),
|
req.getOptions(),
|
||||||
req.getMultiple()
|
req.getMultiple(),
|
||||||
|
req.getProposedName(),
|
||||||
|
req.getProposalDescription()
|
||||||
);
|
);
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
|
|||||||
@@ -28,4 +28,8 @@ public class PostRequest {
|
|||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
private Boolean multiple;
|
private Boolean multiple;
|
||||||
|
|
||||||
|
// fields for category proposal posts
|
||||||
|
private String proposedName;
|
||||||
|
private String proposalDescription;
|
||||||
}
|
}
|
||||||
|
|||||||
20
backend/src/main/java/com/openisle/dto/ProposalDto.java
Normal file
20
backend/src/main/java/com/openisle/dto/ProposalDto.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
|
|||||||
import com.openisle.dto.PollDto;
|
import com.openisle.dto.PollDto;
|
||||||
import com.openisle.dto.PostDetailDto;
|
import com.openisle.dto.PostDetailDto;
|
||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
|
import com.openisle.dto.ProposalDto;
|
||||||
import com.openisle.dto.ReactionDto;
|
import com.openisle.dto.ReactionDto;
|
||||||
|
import com.openisle.model.CategoryProposalPost;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.model.LotteryPost;
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.model.PollPost;
|
import com.openisle.model.PollPost;
|
||||||
@@ -113,26 +115,40 @@ public class PostMapper {
|
|||||||
dto.setLottery(l);
|
dto.setLottery(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post instanceof PollPost pp) {
|
if (post instanceof CategoryProposalPost cp) {
|
||||||
PollDto p = new PollDto();
|
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
|
||||||
p.setOptions(pp.getOptions());
|
proposalDto.setProposalStatus(cp.getProposalStatus());
|
||||||
p.setVotes(pp.getVotes());
|
proposalDto.setProposedName(cp.getProposedName());
|
||||||
p.setEndTime(pp.getEndTime());
|
proposalDto.setDescription(cp.getDescription());
|
||||||
p.setParticipants(
|
proposalDto.setApproveThreshold(cp.getApproveThreshold());
|
||||||
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
proposalDto.setQuorum(cp.getQuorum());
|
||||||
);
|
proposalDto.setStartAt(cp.getStartAt());
|
||||||
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
proposalDto.setResultSnapshot(cp.getResultSnapshot());
|
||||||
.findByPostId(pp.getId())
|
proposalDto.setRejectReason(cp.getRejectReason());
|
||||||
.stream()
|
dto.setPoll(proposalDto);
|
||||||
.collect(
|
} else if (post instanceof PollPost pp) {
|
||||||
Collectors.groupingBy(
|
dto.setPoll(buildPollDto(pp, new PollDto()));
|
||||||
PollVote::getOptionIndex,
|
|
||||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
p.setOptionParticipants(optionParticipants);
|
|
||||||
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
|
||||||
dto.setPoll(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
public enum CategoryProposalStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ public enum NotificationType {
|
|||||||
POLL_RESULT_OWNER,
|
POLL_RESULT_OWNER,
|
||||||
/** A poll you participated in has concluded */
|
/** A poll you participated in has concluded */
|
||||||
POLL_RESULT_PARTICIPANT,
|
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 */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
/** Someone donated to your post */
|
/** Someone donated to your post */
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ public enum PostType {
|
|||||||
NORMAL,
|
NORMAL,
|
||||||
LOTTERY,
|
LOTTERY,
|
||||||
POLL,
|
POLL,
|
||||||
|
PROPOSAL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ package com.openisle.service;
|
|||||||
|
|
||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import com.openisle.mapper.PostMapper;
|
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
|
import com.openisle.repository.CategoryProposalPostRepository;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import com.openisle.repository.CommentRepository;
|
import com.openisle.repository.CommentRepository;
|
||||||
import com.openisle.repository.LotteryPostRepository;
|
import com.openisle.repository.LotteryPostRepository;
|
||||||
@@ -21,7 +21,6 @@ import com.openisle.service.EmailSender;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -54,6 +52,7 @@ public class PostService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final LotteryPostRepository lotteryPostRepository;
|
private final LotteryPostRepository lotteryPostRepository;
|
||||||
private final PollPostRepository pollPostRepository;
|
private final PollPostRepository pollPostRepository;
|
||||||
|
private final CategoryProposalPostRepository categoryProposalPostRepository;
|
||||||
private final PollVoteRepository pollVoteRepository;
|
private final PollVoteRepository pollVoteRepository;
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -71,11 +70,17 @@ public class PostService {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService postChangeLogService;
|
private final PostChangeLogService postChangeLogService;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final CategoryService categoryService;
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@ public class PostService {
|
|||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
LotteryPostRepository lotteryPostRepository,
|
LotteryPostRepository lotteryPostRepository,
|
||||||
PollPostRepository pollPostRepository,
|
PollPostRepository pollPostRepository,
|
||||||
|
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||||
PollVoteRepository pollVoteRepository,
|
PollVoteRepository pollVoteRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
SubscriptionService subscriptionService,
|
SubscriptionService subscriptionService,
|
||||||
@@ -107,7 +113,8 @@ public class PostService {
|
|||||||
PointHistoryRepository pointHistoryRepository,
|
PointHistoryRepository pointHistoryRepository,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||||
RedisTemplate redisTemplate,
|
RedisTemplate redisTemplate,
|
||||||
SearchIndexEventPublisher searchIndexEventPublisher
|
SearchIndexEventPublisher searchIndexEventPublisher,
|
||||||
|
CategoryService categoryService
|
||||||
) {
|
) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -115,6 +122,7 @@ public class PostService {
|
|||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.lotteryPostRepository = lotteryPostRepository;
|
this.lotteryPostRepository = lotteryPostRepository;
|
||||||
this.pollPostRepository = pollPostRepository;
|
this.pollPostRepository = pollPostRepository;
|
||||||
|
this.categoryProposalPostRepository = categoryProposalPostRepository;
|
||||||
this.pollVoteRepository = pollVoteRepository;
|
this.pollVoteRepository = pollVoteRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.subscriptionService = subscriptionService;
|
this.subscriptionService = subscriptionService;
|
||||||
@@ -135,6 +143,7 @@ public class PostService {
|
|||||||
|
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||||
|
this.categoryService = categoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -160,6 +169,24 @@ public class PostService {
|
|||||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||||
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
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() {
|
public PublishMode getPublishMode() {
|
||||||
@@ -232,10 +259,12 @@ public class PostService {
|
|||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
java.util.List<String> options,
|
java.util.List<String> options,
|
||||||
Boolean multiple
|
Boolean multiple,
|
||||||
|
String proposedName,
|
||||||
|
String proposalDescription
|
||||||
) {
|
) {
|
||||||
// 限制访问次数
|
// 限制访问次数
|
||||||
boolean limitResult = postRateLimit(username);
|
boolean limitResult = isPostLimitReached(username);
|
||||||
if (!limitResult) {
|
if (!limitResult) {
|
||||||
throw new RateLimitException("Too many posts");
|
throw new RateLimitException("Too many posts");
|
||||||
}
|
}
|
||||||
@@ -278,6 +307,25 @@ public class PostService {
|
|||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
pp.setMultiple(multiple != null && multiple);
|
pp.setMultiple(multiple != null && multiple);
|
||||||
post = pp;
|
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 {
|
} else {
|
||||||
post = new Post();
|
post = new Post();
|
||||||
}
|
}
|
||||||
@@ -290,6 +338,8 @@ public class PostService {
|
|||||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||||
if (post instanceof LotteryPost) {
|
if (post instanceof LotteryPost) {
|
||||||
post = lotteryPostRepository.save((LotteryPost) post);
|
post = lotteryPostRepository.save((LotteryPost) post);
|
||||||
|
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||||
|
post = categoryProposalPostRepository.save(categoryProposalPost);
|
||||||
} else if (post instanceof PollPost) {
|
} else if (post instanceof PollPost) {
|
||||||
post = pollPostRepository.save((PollPost) post);
|
post = pollPostRepository.save((PollPost) post);
|
||||||
} else {
|
} else {
|
||||||
@@ -344,6 +394,12 @@ public class PostService {
|
|||||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
);
|
);
|
||||||
scheduledFinalizations.put(lp.getId(), future);
|
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) {
|
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||||
@@ -354,24 +410,110 @@ public class PostService {
|
|||||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(post);
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
}
|
}
|
||||||
|
markPostLimit(author.getUsername());
|
||||||
return post;
|
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
|
* @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 key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
String result = (String) redisTemplate.opsForValue().get(key);
|
String result = (String) redisTemplate.opsForValue().get(key);
|
||||||
//最近没有创建过文章
|
return StringUtils.isEmpty(result);
|
||||||
if (StringUtils.isEmpty(result)) {
|
}
|
||||||
// 限制频率为5分钟
|
|
||||||
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
/**
|
||||||
return true;
|
* 标记用户发帖,触发limit计时
|
||||||
}
|
* @param username
|
||||||
return false;
|
*/
|
||||||
|
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)
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
@@ -450,6 +592,9 @@ public class PostService {
|
|||||||
pollPostRepository
|
pollPostRepository
|
||||||
.findById(postId)
|
.findById(postId)
|
||||||
.ifPresent(pp -> {
|
.ifPresent(pp -> {
|
||||||
|
if (pp instanceof CategoryProposalPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pp.isResultAnnounced()) {
|
if (pp.isResultAnnounced()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||||
spring.data.redis.port=${REDIS_PORT:6379}
|
spring.data.redis.port=${REDIS_PORT:6379}
|
||||||
spring.data.redis.database=${REDIS_DATABASE:0}
|
spring.data.redis.database=${REDIS_DATABASE:0}
|
||||||
|
spring.data.redis.password=${REDIS_PASS: null}
|
||||||
|
|
||||||
# for jwt
|
# for jwt
|
||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -76,6 +76,15 @@ class PostControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private MedalService medalService;
|
private MedalService medalService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private CategoryService categoryService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private TagService tagService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PointService pointService;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
||||||
|
|
||||||
@@ -117,6 +126,11 @@ class PostControllerTest {
|
|||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
isNull()
|
isNull()
|
||||||
)
|
)
|
||||||
).thenReturn(post);
|
).thenReturn(post);
|
||||||
@@ -266,6 +280,11 @@ class PostControllerTest {
|
|||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
any()
|
any()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -52,6 +53,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -104,6 +106,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -130,6 +133,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -195,6 +199,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -221,6 +226,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -260,6 +266,11 @@ class PostServiceTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -273,6 +284,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -299,6 +311,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -367,6 +380,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -393,6 +407,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
|||||||
# Web push configuration
|
# Web push configuration
|
||||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||||
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# OpenSearch Service
|
# OpenSearch Service
|
||||||
opensearch:
|
opensearch:
|
||||||
@@ -61,6 +65,9 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
dashboards:
|
dashboards:
|
||||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
image: opensearchproject/opensearch-dashboards:3.0.0
|
||||||
@@ -75,6 +82,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management
|
image: rabbitmq:3.13-management
|
||||||
@@ -98,6 +109,10 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
@@ -111,6 +126,10 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||||
springboot:
|
springboot:
|
||||||
@@ -155,6 +174,9 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
|
||||||
websocket-service:
|
websocket-service:
|
||||||
image: maven:3.9-eclipse-temurin-17
|
image: maven:3.9-eclipse-temurin-17
|
||||||
@@ -186,6 +208,10 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
frontend_dev:
|
frontend_dev:
|
||||||
image: node:20
|
image: node:20
|
||||||
@@ -208,6 +234,28 @@ services:
|
|||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- 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:
|
frontend_service:
|
||||||
build:
|
build:
|
||||||
@@ -226,13 +274,13 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["staging", "prod"]
|
profiles:
|
||||||
|
- prod
|
||||||
|
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||||
loopback_8080:
|
loopback_8080:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
|
||||||
command:
|
command:
|
||||||
- -d
|
- -d
|
||||||
- -d
|
- -d
|
||||||
@@ -243,13 +291,37 @@ services:
|
|||||||
springboot:
|
springboot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
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:
|
loopback_8082:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
@@ -265,13 +337,37 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
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:8082(WS 纯 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:
|
networks:
|
||||||
openisle-network:
|
openisle-network:
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
<span class="poll-row-title">投票选项</span>
|
<span class="poll-row-title">投票选项</span>
|
||||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||||
<i
|
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
|
||||||
v-if="data.options.length > 2"
|
|
||||||
class="fa-solid fa-xmark remove-option-icon"
|
|
||||||
@click="removeOption(idx)"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="add-option" @click="addOption">添加选项</div>
|
<div class="add-option" @click="addOption">添加选项</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
<div class="post-poll-container" v-if="poll">
|
<div class="post-poll-container" v-if="poll">
|
||||||
<div class="poll-top-container">
|
<div class="poll-top-container">
|
||||||
<div class="poll-options-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-if="showPollResult || pollEnded || hasVoted">
|
||||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||||
<div class="poll-option-info-container">
|
<div class="poll-option-info-container">
|
||||||
@@ -29,16 +53,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<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">
|
<template v-if="poll.multiple">
|
||||||
<div
|
<div
|
||||||
v-for="(opt, idx) in poll.options"
|
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-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||||
<div v-else class="poll-option-hint">
|
<div v-else class="poll-option-hint">
|
||||||
<div>您已投票,等待结束查看结果</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const showPollResult = ref(false)
|
const showPollResult = ref(false)
|
||||||
|
|
||||||
|
const isProposal = computed(() =>
|
||||||
|
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
|
||||||
|
)
|
||||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||||
const pollVotes = computed(() => props.poll?.votes || {})
|
const pollVotes = computed(() => props.poll?.votes || {})
|
||||||
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
|
|||||||
padding: 10px;
|
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 {
|
.poll-option-button {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-title-section {
|
.poll-title-section {
|
||||||
display: flex;
|
|
||||||
gap: 30px;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 20px;
|
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 {
|
.poll-option-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default {
|
|||||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||||
|
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
frontend_nuxt/components/ProposalForm.vue
Normal file
77
frontend_nuxt/components/ProposalForm.vue
Normal 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>
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<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-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div
|
<div
|
||||||
@@ -72,6 +67,7 @@
|
|||||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-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" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -116,7 +112,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
|
|
||||||
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||||
<InfiniteLoadMore
|
<InfiniteLoadMore
|
||||||
v-if="articles.length > 0"
|
v-if="articles.length > 0"
|
||||||
:key="ioKey"
|
:key="ioKey"
|
||||||
@@ -572,6 +568,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.pinned-icon,
|
.pinned-icon,
|
||||||
.lottery-icon,
|
.lottery-icon,
|
||||||
.featured-icon,
|
.featured-icon,
|
||||||
|
.proposal-icon,
|
||||||
.poll-icon {
|
.poll-icon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|||||||
@@ -75,7 +75,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.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>
|
||||||
</span>
|
</span>
|
||||||
回复了
|
回复了
|
||||||
@@ -85,7 +87,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -115,7 +119,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -162,7 +168,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
进行了表态
|
进行了表态
|
||||||
@@ -251,6 +259,38 @@
|
|||||||
已出结果
|
已出结果
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</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'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -287,7 +327,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.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>
|
||||||
回复了
|
回复了
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -295,7 +337,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.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>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -775,6 +817,10 @@ const formatType = (t) => {
|
|||||||
return '发布的投票结果已公布'
|
return '发布的投票结果已公布'
|
||||||
case 'POLL_RESULT_PARTICIPANT':
|
case 'POLL_RESULT_PARTICIPANT':
|
||||||
return '参与的投票结果已公布'
|
return '参与的投票结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
|
||||||
|
return '分类提案结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
|
||||||
|
return '参与的分类提案结果已公布'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||||
|
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,6 +51,7 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
|||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import LotteryForm from '~/components/LotteryForm.vue'
|
import LotteryForm from '~/components/LotteryForm.vue'
|
||||||
import PollForm from '~/components/PollForm.vue'
|
import PollForm from '~/components/PollForm.vue'
|
||||||
|
import ProposalForm from '~/components/ProposalForm.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -76,6 +78,10 @@ const poll = reactive({
|
|||||||
endTime: null,
|
endTime: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
const proposal = reactive({
|
||||||
|
proposedName: '',
|
||||||
|
proposalDescription: '',
|
||||||
|
})
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
@@ -123,6 +129,8 @@ const clearPost = async () => {
|
|||||||
poll.options = ['', '']
|
poll.options = ['', '']
|
||||||
poll.endTime = null
|
poll.endTime = null
|
||||||
poll.multiple = false
|
poll.multiple = false
|
||||||
|
proposal.proposedName = ''
|
||||||
|
proposal.proposalDescription = ''
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -283,6 +291,12 @@ const submitPost = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (postType.value === 'PROPOSAL') {
|
||||||
|
if (!proposal.proposedName.trim()) {
|
||||||
|
toast.error('请填写拟议分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
@@ -303,35 +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,
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
CheckOne,
|
CheckOne,
|
||||||
Share,
|
Share,
|
||||||
Financing,
|
Financing,
|
||||||
|
Hands,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -165,4 +166,5 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
nuxtApp.vueApp.component('Financing', Financing)
|
nuxtApp.vueApp.component('Financing', Financing)
|
||||||
|
nuxtApp.vueApp.component('Hands', Hands)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const iconMap = {
|
|||||||
POLL_VOTE: 'ChartHistogram',
|
POLL_VOTE: 'ChartHistogram',
|
||||||
POLL_RESULT_OWNER: 'RankingList',
|
POLL_RESULT_OWNER: 'RankingList',
|
||||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
|
||||||
MENTION: 'HashtagKey',
|
MENTION: 'HashtagKey',
|
||||||
POST_DELETED: 'ClearIcon',
|
POST_DELETED: 'ClearIcon',
|
||||||
POST_FEATURED: 'Star',
|
POST_FEATURED: 'Star',
|
||||||
@@ -254,7 +256,9 @@ function createFetchNotifications() {
|
|||||||
} else if (
|
} else if (
|
||||||
n.type === 'POLL_VOTE' ||
|
n.type === 'POLL_VOTE' ||
|
||||||
n.type === 'POLL_RESULT_OWNER' ||
|
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({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
Reference in New Issue
Block a user