mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Compare commits
43 Commits
feature/ui
...
feature/ng
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e70a3d273 | ||
|
|
779bb2db78 | ||
|
|
b3b0b194a3 | ||
|
|
e21b2f42d2 | ||
|
|
05a5acee7e | ||
|
|
755982098b | ||
|
|
af24263c0a | ||
|
|
8fd268bd11 | ||
|
|
a24bd81942 | ||
|
|
8a008a090a | ||
|
|
5dfb69e636 | ||
|
|
499069573e | ||
|
|
636912941a | ||
|
|
bdcc1488b9 | ||
|
|
d33bd233af | ||
|
|
efe4b97d83 | ||
|
|
8a256e167d | ||
|
|
9c5a49a47f | ||
|
|
2271bbbd1d | ||
|
|
d6470e04fc | ||
|
|
4db35a4531 | ||
|
|
1906ffd8aa | ||
|
|
426884385f | ||
|
|
8193c92c91 | ||
|
|
90649b422d | ||
|
|
67efb64ccc | ||
|
|
23d8eafc08 | ||
|
|
d1cc16e31e | ||
|
|
0f1c45b155 | ||
|
|
8ed11df99c | ||
|
|
458b125834 | ||
|
|
971a3d36c6 | ||
|
|
e5d66d73cb | ||
|
|
a9608cc706 | ||
|
|
232f40151b | ||
|
|
3b3f99754d | ||
|
|
e14566ee66 | ||
|
|
892312c6d4 | ||
|
|
dfb31771ff | ||
|
|
bf7df629cc | ||
|
|
c9854e1840 | ||
|
|
3da5d24488 | ||
|
|
76962d6d1c |
@@ -2,6 +2,7 @@
|
||||
SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
WEBSOCKET_PORT=8082
|
||||
OPENISLE_MCP_PORT=8085
|
||||
MYSQL_PORT=3306
|
||||
REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
|
||||
@@ -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 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
176
SECURITY.md
Normal file
176
SECURITY.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.0.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
### How to Report a Security Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via one of the following methods:
|
||||
|
||||
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
|
||||
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
|
||||
|
||||
### What to Include in Your Report
|
||||
|
||||
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
|
||||
|
||||
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit it
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial Response**: We will acknowledge your report within 48 hours
|
||||
- **Status Updates**: We will provide status updates at least every 5 business days
|
||||
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
|
||||
|
||||
### What to Expect
|
||||
|
||||
After you submit a report:
|
||||
|
||||
1. We will confirm receipt of your vulnerability report and may ask for additional information
|
||||
2. We will investigate the issue and determine its impact and severity
|
||||
3. We will work on a fix and coordinate disclosure timing with you
|
||||
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
|
||||
|
||||
## Security Considerations for Deployment
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
|
||||
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
|
||||
- **Session Management**: Configure appropriate session timeout values
|
||||
|
||||
### Database Security
|
||||
|
||||
- Use strong database passwords
|
||||
- Never expose database ports publicly
|
||||
- Use database connection encryption when available
|
||||
- Regularly backup your database
|
||||
|
||||
### API Security
|
||||
|
||||
- Enable rate limiting to prevent abuse
|
||||
- Validate all user inputs on both client and server side
|
||||
- Use HTTPS in production environments
|
||||
- Configure CORS properly to restrict origins
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following sensitive environment variables should be kept secure:
|
||||
|
||||
- `JWT_SECRET` - JWT signing key
|
||||
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
|
||||
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
|
||||
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
|
||||
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
|
||||
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
|
||||
- Database connection strings and credentials
|
||||
- Cloud storage credentials (Tencent COS)
|
||||
|
||||
**Never commit these values to version control or expose them in logs.**
|
||||
|
||||
### File Upload Security
|
||||
|
||||
- Validate file types and sizes
|
||||
- Scan uploaded files for malware
|
||||
- Store uploaded files outside the web root
|
||||
- Use cloud storage with proper access controls
|
||||
|
||||
### Password Security
|
||||
|
||||
- Configure password strength requirements via environment variables
|
||||
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
|
||||
- Implement account lockout after failed login attempts
|
||||
|
||||
### Web Push Notifications
|
||||
|
||||
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
|
||||
- Only send notifications to users who have explicitly opted in
|
||||
- Validate notification payloads
|
||||
|
||||
### Dependency Management
|
||||
|
||||
- Regularly update dependencies to patch known vulnerabilities
|
||||
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
|
||||
- Monitor GitHub security advisories for this project
|
||||
|
||||
### Production Deployment Checklist
|
||||
|
||||
- [ ] Use HTTPS/TLS for all connections
|
||||
- [ ] Set strong, unique secrets for all environment variables
|
||||
- [ ] Enable CSRF protection
|
||||
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
|
||||
- [ ] Disable debug mode and verbose error messages
|
||||
- [ ] Set up proper logging and monitoring
|
||||
- [ ] Implement rate limiting and DDoS protection
|
||||
- [ ] Regular security updates and patches
|
||||
- [ ] Database backups and disaster recovery plan
|
||||
- [ ] Restrict admin access to trusted IPs when possible
|
||||
|
||||
## Known Security Features
|
||||
|
||||
OpenIsle includes the following security features:
|
||||
|
||||
- JWT-based authentication with configurable expiration
|
||||
- OAuth 2.0 integration with major providers
|
||||
- Password strength validation
|
||||
- Protection codes for sensitive operations
|
||||
- Input validation and sanitization
|
||||
- SQL injection prevention through ORM (JPA/Hibernate)
|
||||
- XSS protection in Vue.js templates
|
||||
- CSRF protection (Spring Security)
|
||||
|
||||
## Security Best Practices for Contributors
|
||||
|
||||
- Never commit credentials, API keys, or secrets
|
||||
- Follow secure coding practices (OWASP Top 10)
|
||||
- Validate and sanitize all user inputs
|
||||
- Use parameterized queries for database operations
|
||||
- Implement proper error handling without exposing sensitive information
|
||||
- Write security tests for new features
|
||||
- Review code for security issues before submitting PRs
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
When we receive a security bug report, we will:
|
||||
|
||||
1. Confirm the problem and determine affected versions
|
||||
2. Audit code to find any similar problems
|
||||
3. Prepare fixes for all supported versions
|
||||
4. Release patches as soon as possible
|
||||
|
||||
We appreciate your help in keeping OpenIsle and its users safe!
|
||||
|
||||
## Attribution
|
||||
|
||||
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
|
||||
|
||||
- Security advisory
|
||||
- Release notes
|
||||
- A security hall of fame (if established)
|
||||
|
||||
If you prefer to remain anonymous, we will respect your wishes.
|
||||
|
||||
## Contact
|
||||
|
||||
For any security-related questions or concerns, please reach out through the channels mentioned above.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep OpenIsle secure!
|
||||
@@ -19,6 +19,7 @@ JWT_EXPIRATION=2592000000
|
||||
# === Redis ===
|
||||
REDIS_HOST=<Redis 地址>
|
||||
REDIS_PORT=<Redis 端口>
|
||||
REDIS_PASS=<Redis 密码>
|
||||
|
||||
# === Resend ===
|
||||
RESEND_API_KEY=<你的resend-api-key>
|
||||
|
||||
@@ -66,6 +66,7 @@ public class PostController {
|
||||
req.getContent(),
|
||||
req.getTagIds(),
|
||||
req.getType(),
|
||||
req.getPostVisibleScopeType(),
|
||||
req.getPrizeDescription(),
|
||||
req.getPrizeIcon(),
|
||||
req.getPrizeCount(),
|
||||
@@ -73,7 +74,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());
|
||||
@@ -101,7 +104,8 @@ public class PostController {
|
||||
req.getCategoryId(),
|
||||
req.getTitle(),
|
||||
req.getContent(),
|
||||
req.getTagIds()
|
||||
req.getTagIds(),
|
||||
req.getPostVisibleScopeType()
|
||||
);
|
||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import com.openisle.model.PostChangeType;
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
@@ -29,5 +30,7 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.openisle.dto;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
||||
|
||||
// optional for lottery posts
|
||||
private PostType type;
|
||||
private PostVisibleScopeType postVisibleScopeType;
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
@@ -28,4 +31,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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
||||
import com.openisle.model.PostType;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import com.openisle.model.PostVisibleScopeType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
||||
private PollDto poll;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
private PostVisibleScopeType visibleScope;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -52,6 +52,9 @@ public class PostChangeLogMapper {
|
||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||
dto.setOldFeatured(f.isOldFeatured());
|
||||
dto.setNewFeatured(f.isNewFeatured());
|
||||
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
||||
dto.setOldVisibleScope(v.getOldVisibleScope());
|
||||
dto.setNewVisibleScope(v.getNewVisibleScope());
|
||||
} else if (log instanceof PostDonateChangeLog d) {
|
||||
dto.setAmount(d.getAmount());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -73,6 +75,7 @@ public class PostMapper {
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
dto.setVisibleScope(post.getVisibleScope());
|
||||
|
||||
List<ReactionDto> reactions = reactionService
|
||||
.getReactionsForPost(post.getId())
|
||||
@@ -113,26 +116,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
/** 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 */
|
||||
|
||||
@@ -66,6 +66,10 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum PostChangeType {
|
||||
CLOSED,
|
||||
PINNED,
|
||||
FEATURED,
|
||||
VISIBLE_SCOPE,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum PostType {
|
||||
NORMAL,
|
||||
LOTTERY,
|
||||
POLL,
|
||||
PROPOSAL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "post_visible_scope_change_logs")
|
||||
public class PostVisibleScopeChangeLog extends PostChangeLog {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType oldVisibleScope;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private PostVisibleScopeType newVisibleScope;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
|
||||
public enum PostVisibleScopeType {
|
||||
ALL,
|
||||
ONLY_ME,
|
||||
ONLY_REGISTER;
|
||||
|
||||
/**
|
||||
* 防止画面传递错误的值
|
||||
* @param value
|
||||
* @return
|
||||
*/
|
||||
@JsonCreator
|
||||
public static PostVisibleScopeType fromString(String value) {
|
||||
if (value == null) return ALL;
|
||||
for (PostVisibleScopeType type : PostVisibleScopeType.values()) {
|
||||
if (type.name().equalsIgnoreCase(value)) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
// 不匹配时给默认值,而不是抛异常
|
||||
return ALL;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String toValue() {
|
||||
return this.name();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVisibleScopeChange(
|
||||
Post post,
|
||||
User user,
|
||||
PostVisibleScopeType oldVisibleScope,
|
||||
PostVisibleScopeType newVisibleScope
|
||||
) {
|
||||
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
|
||||
log.setPost(post);
|
||||
log.setUser(user);
|
||||
log.setType(PostChangeType.VISIBLE_SCOPE);
|
||||
log.setOldVisibleScope(oldVisibleScope);
|
||||
log.setNewVisibleScope(newVisibleScope);
|
||||
logRepository.save(log);
|
||||
}
|
||||
|
||||
public void recordVoteResult(Post post) {
|
||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||
log.setPost(post);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
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 +22,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 +32,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 +53,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 +71,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 +95,7 @@ public class PostService {
|
||||
TagRepository tagRepository,
|
||||
LotteryPostRepository lotteryPostRepository,
|
||||
PollPostRepository pollPostRepository,
|
||||
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||
PollVoteRepository pollVoteRepository,
|
||||
NotificationService notificationService,
|
||||
SubscriptionService subscriptionService,
|
||||
@@ -107,7 +114,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 +123,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 +144,7 @@ public class PostService {
|
||||
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||
this.categoryService = categoryService;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -160,6 +170,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() {
|
||||
@@ -225,6 +253,7 @@ public class PostService {
|
||||
String content,
|
||||
List<Long> tagIds,
|
||||
PostType type,
|
||||
PostVisibleScopeType postVisibleScopeType,
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
@@ -232,10 +261,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 +309,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();
|
||||
}
|
||||
@@ -288,8 +338,18 @@ public class PostService {
|
||||
post.setCategory(category);
|
||||
post.setTags(new HashSet<>(tags));
|
||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||
|
||||
// 什么都没设置的情况下,默认为ALL
|
||||
if (Objects.isNull(postVisibleScopeType)) {
|
||||
post.setVisibleScope(PostVisibleScopeType.ALL);
|
||||
} else {
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
}
|
||||
|
||||
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 +404,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 +420,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 +602,9 @@ public class PostService {
|
||||
pollPostRepository
|
||||
.findById(postId)
|
||||
.ifPresent(pp -> {
|
||||
if (pp instanceof CategoryProposalPost) {
|
||||
return;
|
||||
}
|
||||
if (pp.isResultAnnounced()) {
|
||||
return;
|
||||
}
|
||||
@@ -571,7 +726,7 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||
if (viewer == null) {
|
||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||
throw new com.openisle.exception.NotFoundException("User not found");
|
||||
}
|
||||
User viewerUser = userRepository
|
||||
.findByUsername(viewer)
|
||||
@@ -1002,7 +1157,8 @@ public class PostService {
|
||||
Long categoryId,
|
||||
String title,
|
||||
String content,
|
||||
java.util.List<Long> tagIds
|
||||
List<Long> tagIds,
|
||||
PostVisibleScopeType postVisibleScopeType
|
||||
) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("At least one tag required");
|
||||
@@ -1034,6 +1190,8 @@ public class PostService {
|
||||
post.setContent(content);
|
||||
post.setCategory(category);
|
||||
post.setTags(new java.util.HashSet<>(tags));
|
||||
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||
post.setVisibleScope(postVisibleScopeType);
|
||||
Post updated = postRepository.save(post);
|
||||
imageUploader.adjustReferences(oldContent, content);
|
||||
notificationService.notifyMentions(content, user, updated, null);
|
||||
@@ -1055,6 +1213,14 @@ public class PostService {
|
||||
if (!oldTags.equals(newTags)) {
|
||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||
}
|
||||
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
|
||||
postChangeLogService.recordVisibleScopeChange(
|
||||
updated,
|
||||
user,
|
||||
oldVisibleScope,
|
||||
postVisibleScopeType
|
||||
);
|
||||
}
|
||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||
searchIndexEventPublisher.publishPostSaved(updated);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 @@
|
||||
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
||||
@@ -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
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=production \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||
|
||||
echo "👉 Build images (staging)..."
|
||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
build --pull \
|
||||
--build-arg NUXT_ENV=staging \
|
||||
frontend_service
|
||||
frontend_service mcp
|
||||
|
||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||
up -d --force-recreate --remove-orphans --no-deps \
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||
|
||||
echo "👉 Current status:"
|
||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||
|
||||
@@ -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:
|
||||
@@ -142,8 +161,8 @@ services:
|
||||
condition: service_started
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
# opensearch:
|
||||
# condition: service_healthy
|
||||
command: >
|
||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||
@@ -155,6 +174,35 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/mcp.Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
||||
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
@@ -186,6 +234,10 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
frontend_dev:
|
||||
image: node:20
|
||||
@@ -208,6 +260,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 +300,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 +317,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 +363,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: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:
|
||||
openisle-network:
|
||||
|
||||
21
docker/mcp.Dockerfile
Normal file
21
docker/mcp.Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY mcp/pyproject.toml mcp/README.md ./
|
||||
COPY mcp/src ./src
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8085 \
|
||||
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||
|
||||
EXPOSE 8085
|
||||
|
||||
CMD ["openisle-mcp"]
|
||||
|
||||
@@ -168,9 +168,19 @@ export default {
|
||||
const mobileMenuRef = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const openMenu = () => {
|
||||
if (!open.value) {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit('close')
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
emit('close')
|
||||
} else {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
@@ -275,7 +285,7 @@ export default {
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
expose({ toggle, close, reload, scrollToBottom })
|
||||
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
||||
|
||||
return {
|
||||
open,
|
||||
@@ -308,7 +318,6 @@ export default {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -331,6 +340,7 @@ export default {
|
||||
z-index: 10000;
|
||||
max-height: 300px;
|
||||
min-width: 350px;
|
||||
margin-top: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<SearchDropdown
|
||||
ref="searchDropdown"
|
||||
v-if="!isMobile || showSearch"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<!-- 搜索 -->
|
||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||
<div class="header-icon-item" @click="search">
|
||||
@@ -106,7 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -539,6 +543,7 @@ onMounted(async () => {
|
||||
.header-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 在线人数的数字文字样式(无背景) */
|
||||
|
||||
@@ -3,15 +3,30 @@
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
<div class="login-overlay-text">{{ props.text }}</div>
|
||||
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '请先登录,点击跳转到登录页面',
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '登录',
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
default: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
const goLogin = () => {
|
||||
navigateTo('/login', { replace: true })
|
||||
navigateTo(props.buttonLink, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||
<template v-else>取消精选文章</template>
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
|
||||
变更了文章可见范围, 从 {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
|
||||
{{ formatVisibleScope(log.newVisibleScope) }}
|
||||
</span>
|
||||
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||
>系统已计算投票结果</span
|
||||
>
|
||||
@@ -69,6 +73,17 @@ const props = defineProps({
|
||||
title: String,
|
||||
})
|
||||
|
||||
const VISIBLE_SCOPE_LABELS = {
|
||||
ALL: '全部可见',
|
||||
ONLY_ME: '仅自己可见',
|
||||
ONLY_REGISTER: '仅注册用户可见',
|
||||
}
|
||||
|
||||
const formatVisibleScope = (scope) => {
|
||||
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
|
||||
return VISIBLE_SCOPE_LABELS[scope] ?? scope
|
||||
}
|
||||
|
||||
const diffHtml = computed(() => {
|
||||
// Track theme changes
|
||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
41
frontend_nuxt/components/PostVisibleScopeSelect.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
v-model="selected"
|
||||
:fetch-options="fetchTypes"
|
||||
placeholder="选择帖子可见范围"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
|
||||
export default {
|
||||
name: 'PostVisibleScopeSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
modelValue: { type: String, default: 'ALL' },
|
||||
// options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'ALL', name: '全部可见', icon: 'communication' },
|
||||
{ id: 'ONLY_ME', name: '仅自己可见', icon: 'user-icon' },
|
||||
{ id: 'ONLY_REGISTER', name: '仅注册用户可见', icon: 'peoples-two' },
|
||||
]
|
||||
}
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchTypes, selected }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
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>
|
||||
@@ -17,7 +17,8 @@
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
placeholder="Search"
|
||||
placeholder="键盘点击「/」以触发搜索"
|
||||
ref="searchInput"
|
||||
@input="setSearch(keyword)"
|
||||
/>
|
||||
</div>
|
||||
@@ -48,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
@@ -61,8 +62,48 @@ const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const searchInput = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const isEditableElement = (el) => {
|
||||
if (!el) return false
|
||||
if (el.isContentEditable) return true
|
||||
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||
return true
|
||||
}
|
||||
const role = el.getAttribute ? el.getAttribute('role') : null
|
||||
return role === 'textbox'
|
||||
}
|
||||
|
||||
const focusSearchInput = () => {
|
||||
if (!searchInput.value) return
|
||||
dropdown.value?.openMenu?.()
|
||||
if (typeof searchInput.value.focus === 'function') {
|
||||
try {
|
||||
searchInput.value.focus({ preventScroll: true })
|
||||
} catch (e) {
|
||||
searchInput.value.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalSlash = (event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (isEditableElement(document.activeElement)) return
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleGlobalSlash)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleGlobalSlash)
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
@@ -144,8 +185,7 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-mobile-trigger {
|
||||
@@ -154,7 +194,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
padding: 2px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -202,7 +242,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.result-body {
|
||||
line-height: 1;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -216,4 +256,14 @@ defineExpose({
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,8 +67,10 @@
|
||||
<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 }}
|
||||
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||
|
||||
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||
<InfiniteLoadMore
|
||||
v-if="articles.length > 0"
|
||||
:key="ioKey"
|
||||
@@ -299,6 +296,7 @@ const {
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -340,6 +338,7 @@ const fetchNextPage = async () => {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
@@ -379,7 +378,6 @@ onBeforeUnmount(() => {
|
||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||
|
||||
|
||||
// 页面选项同步到全局状态
|
||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
selectedCategoryGlobal.value = newCategory
|
||||
@@ -544,14 +542,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
.header-item.views {
|
||||
width: 5%;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.article-time,
|
||||
.header-item.activity {
|
||||
width: 10%;
|
||||
justify-content: flex-end;
|
||||
text-align: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.article-item-title {
|
||||
@@ -573,6 +571,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||
.pinned-icon,
|
||||
.lottery-icon,
|
||||
.featured-icon,
|
||||
.proposal-icon,
|
||||
.poll-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--primary-color);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostTypeSelect v-model="postType" />
|
||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -37,6 +38,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,8 +52,10 @@ 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'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -60,6 +64,7 @@ const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const postType = ref('NORMAL')
|
||||
const postVisibleScope = ref('ALL')
|
||||
const lottery = reactive({
|
||||
prizeIcon: '',
|
||||
prizeIconFile: null,
|
||||
@@ -76,6 +81,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)
|
||||
@@ -94,6 +103,7 @@ const loadDraft = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.categoryId || ''
|
||||
selectedTags.value = data.tagIds || []
|
||||
postVisibleScope.value = data.visiblescope
|
||||
|
||||
toast.success('草稿已加载')
|
||||
}
|
||||
@@ -109,6 +119,7 @@ const clearPost = async () => {
|
||||
content.value = ''
|
||||
selectedCategory.value = ''
|
||||
selectedTags.value = []
|
||||
postVisibleScope.value = 'ALL'
|
||||
postType.value = 'NORMAL'
|
||||
lottery.prizeIcon = ''
|
||||
lottery.prizeIconFile = null
|
||||
@@ -123,6 +134,8 @@ const clearPost = async () => {
|
||||
poll.options = ['', '']
|
||||
poll.endTime = null
|
||||
poll.multiple = false
|
||||
proposal.proposedName = ''
|
||||
proposal.proposalDescription = ''
|
||||
|
||||
// 删除草稿
|
||||
const token = getToken()
|
||||
@@ -160,6 +173,7 @@ const saveDraft = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value || null,
|
||||
tagIds,
|
||||
postVisibleScopeType:postVisibleScope.value
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
@@ -283,6 +297,12 @@ const submitPost = async () => {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (postType.value === 'PROPOSAL') {
|
||||
if (!proposal.proposedName.trim()) {
|
||||
toast.error('请填写拟议分类名称')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
await ensureTags(token)
|
||||
@@ -303,36 +323,46 @@ 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,
|
||||
postVisibleScopeType: postVisibleScope.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) {
|
||||
if (data.reward && data.reward > 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="post-options-left">
|
||||
<CategorySelect v-model="selectedCategory" />
|
||||
<TagSelect v-model="selectedTags" creatable />
|
||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||
</div>
|
||||
<div class="post-options-right">
|
||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
@@ -51,6 +53,7 @@ const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const selectedVisibleScope = ref('ALL')
|
||||
const isWaitingPosting = ref(false)
|
||||
const isAiLoading = ref(false)
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
||||
content.value = data.content || ''
|
||||
selectedCategory.value = data.category.id || ''
|
||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||
selectedVisibleScope.value = data.visibleScope
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('加载失败')
|
||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
||||
content: content.value,
|
||||
categoryId: selectedCategory.value,
|
||||
tagIds: selectedTags.value,
|
||||
postVisibleScopeType:selectedVisibleScope.value
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
<template>
|
||||
<div class="post-page-container">
|
||||
<div v-if="isRestricted" class="restricted-content">
|
||||
<template v-if="visibleScope === 'ONLY_ME'">
|
||||
<LoginOverlay
|
||||
text="这是一篇私密文章,仅作者本人及管理员可见"
|
||||
button-text="返回首页"
|
||||
button-link="/"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
||||
<LoginOverlay
|
||||
text="这是一篇仅登录用户可见的文章,请先登录"
|
||||
button-text="登录"
|
||||
button-link="/login"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="post-page-container">
|
||||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
@@ -16,7 +33,9 @@
|
||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||
<div v-if="closed" class="article-gray-button">已关闭</div>
|
||||
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
||||
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
||||
<div
|
||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||
class="article-subscribe-button"
|
||||
@@ -92,7 +111,7 @@
|
||||
></div>
|
||||
|
||||
<div class="article-footer-container">
|
||||
<div class="option-container">
|
||||
<div class="article-option-container">
|
||||
<ReactionsGroup
|
||||
ref="postReactionsGroupRef"
|
||||
v-model="postReactions"
|
||||
@@ -165,25 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-page-scroller-container">
|
||||
<div class="scroller">
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||||
<div class="scroller-middle">
|
||||
<input
|
||||
type="range"
|
||||
class="scroller-range"
|
||||
:max="totalPosts"
|
||||
:min="1"
|
||||
v-model.number="currentIndex"
|
||||
@input="onSliderInput"
|
||||
/>
|
||||
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||||
</div>
|
||||
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<vue-easy-lightbox
|
||||
:visible="lightboxVisible"
|
||||
:index="lightboxIndex"
|
||||
@@ -228,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { Lock } from '@icon-park/vue-next'
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -241,6 +242,13 @@ const author = ref('')
|
||||
const postContent = ref('')
|
||||
const category = ref('')
|
||||
const tags = ref([])
|
||||
const visibleScope = ref('ALL') // 可见范围
|
||||
const isRestricted = computed(() => {
|
||||
return (
|
||||
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
||||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
||||
)
|
||||
})
|
||||
const postReactions = ref([])
|
||||
const postReactionsGroupRef = ref(null)
|
||||
const postLikeCount = computed(
|
||||
@@ -408,6 +416,14 @@ const changeLogIcon = (l) => {
|
||||
} else {
|
||||
return 'dislike'
|
||||
}
|
||||
} else if (l.type === 'VISIBLE_SCOPE') {
|
||||
if (l.newVisibleScope === 'ONLY_ME') {
|
||||
return 'lock-one'
|
||||
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
|
||||
return 'peoples-two'
|
||||
} else {
|
||||
return 'communication'
|
||||
}
|
||||
} else if (l.type === 'VOTE_RESULT') {
|
||||
return 'check-one'
|
||||
} else if (l.type === 'LOTTERY_RESULT') {
|
||||
@@ -438,6 +454,8 @@ const mapChangeLog = (l) => ({
|
||||
newCategory: l.newCategory,
|
||||
oldTags: l.oldTags,
|
||||
newTags: l.newTags,
|
||||
oldVisibleScope: l.oldVisibleScope,
|
||||
newVisibleScope: l.newVisibleScope,
|
||||
amount: l.amount,
|
||||
icon: changeLogIcon(l),
|
||||
})
|
||||
@@ -497,15 +515,27 @@ const onCommentDeleted = (id) => {
|
||||
fetchTimeline()
|
||||
}
|
||||
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
const {
|
||||
data: postData,
|
||||
pending: pendingPost,
|
||||
error: postError,
|
||||
refresh: refreshPost,
|
||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||
server: true,
|
||||
lazy: false,
|
||||
})
|
||||
} = await useAsyncData(
|
||||
`post-${postId}`,
|
||||
async () => {
|
||||
try {
|
||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||
} catch (err) {}
|
||||
},
|
||||
{
|
||||
server: false,
|
||||
lazy: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||
@@ -519,6 +549,7 @@ watchEffect(() => {
|
||||
title.value = data.title
|
||||
category.value = data.category
|
||||
tags.value = data.tags || []
|
||||
visibleScope.value = data.visibleScope || 'ALL'
|
||||
postReactions.value = data.reactions || []
|
||||
subscribed.value = !!data.subscribed
|
||||
status.value = data.status
|
||||
@@ -935,7 +966,7 @@ onMounted(async () => {
|
||||
<style>
|
||||
.post-page-container {
|
||||
background-color: var(--background-color);
|
||||
display: flex;
|
||||
display: block;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -948,9 +979,10 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.post-page-main-container {
|
||||
position: relative;
|
||||
scrollbar-width: none;
|
||||
padding: 20px;
|
||||
width: calc(85% - 40px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.info-content-text p code {
|
||||
@@ -1002,6 +1034,35 @@ onMounted(async () => {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background-color: #eee;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.skeleton::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
||||
transform: translateX(-100%);
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
z-index: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -1106,7 +1167,7 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-closed-button,
|
||||
.article-gray-button,
|
||||
.article-subscribe-button-text,
|
||||
.article-featured-button,
|
||||
.article-unsubscribe-button-text {
|
||||
@@ -1159,7 +1220,7 @@ onMounted(async () => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.article-closed-button {
|
||||
.article-gray-button {
|
||||
background-color: var(--background-color);
|
||||
color: gray;
|
||||
border: 1px solid gray;
|
||||
@@ -1286,7 +1347,7 @@ onMounted(async () => {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.option-container {
|
||||
.article-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -1341,6 +1402,76 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ======== 权限锁定状态 ======== */
|
||||
.is-blurred {
|
||||
filter: blur(10px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.restricted-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
/* 中央提示框 */
|
||||
.restricted-content {
|
||||
background: #ffff;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.restricted-icon {
|
||||
font-size: 60px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.restricted-button {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
padding: 10px 18px;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.restricted-button:hover {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
|
||||
.restricted-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.post-page-main-container {
|
||||
width: calc(100% - 20px);
|
||||
|
||||
@@ -81,6 +81,8 @@ import {
|
||||
CheckOne,
|
||||
Share,
|
||||
Financing,
|
||||
Hands,
|
||||
PreviewCloseOne,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
@@ -165,4 +167,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||
nuxtApp.vueApp.component('Share', Share)
|
||||
nuxtApp.vueApp.component('Financing', Financing)
|
||||
nuxtApp.vueApp.component('Hands', Hands)
|
||||
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||
})
|
||||
|
||||
@@ -268,23 +268,21 @@ export function stripMarkdownLength(text, length) {
|
||||
|
||||
// 朴素文本带贴吧表情
|
||||
export function stripMarkdownWithTiebaMoji(text, length){
|
||||
console.error(text)
|
||||
if (!text) return ''
|
||||
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
console.error(plain)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
// Markdown 转成纯文本
|
||||
const plain = stripMarkdown(text)
|
||||
// 替换 :tieba123: 为 <img>
|
||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||
const key = `tieba${num}`
|
||||
const file = tiebaEmoji[key]
|
||||
return file
|
||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||
: match // 没有匹配到图片则保留原样
|
||||
})
|
||||
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
// 截断纯文本长度(防止撑太长)
|
||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
||||
return truncated
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
37
mcp/README.md
Normal file
37
mcp/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
||||
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
|
||||
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
|
||||
other resources.
|
||||
|
||||
## Configuration
|
||||
|
||||
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
|
||||
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
|
||||
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
|
||||
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
|
||||
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
|
||||
|
||||
## Running locally
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||
|
||||
## Available tools
|
||||
|
||||
| Tool | Description |
|
||||
| --- | --- |
|
||||
| `search` | Perform a global search against the OpenIsle backend. |
|
||||
|
||||
The tool returns structured data describing each search hit including highlighted snippets when
|
||||
provided by the backend.
|
||||
|
||||
27
mcp/pyproject.toml
Normal file
27
mcp/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||
readme = "README.md"
|
||||
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"mcp>=1.19.0",
|
||||
"httpx>=0.28,<0.29",
|
||||
"pydantic>=2.12,<3",
|
||||
"pydantic-settings>=2.11,<3"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build]
|
||||
packages = ["src/openisle_mcp"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
6
mcp/src/openisle_mcp/__init__.py
Normal file
6
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .config import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
|
||||
52
mcp/src/openisle_mcp/config.py
Normal file
52
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Application configuration helpers for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.networks import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration for the MCP server."""
|
||||
|
||||
backend_base_url: AnyHttpUrl = Field(
|
||||
"http://springboot:8080",
|
||||
description="Base URL for the OpenIsle backend service.",
|
||||
)
|
||||
host: str = Field(
|
||||
"0.0.0.0",
|
||||
description="Host interface to bind when running with HTTP transports.",
|
||||
)
|
||||
port: int = Field(
|
||||
8085,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="TCP port for HTTP transports.",
|
||||
)
|
||||
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
||||
"streamable-http",
|
||||
description="MCP transport to use when running the server.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
10.0,
|
||||
gt=0,
|
||||
description="Timeout (seconds) for backend search requests.",
|
||||
)
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="OPENISLE_MCP_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return cached application settings."""
|
||||
|
||||
return Settings()
|
||||
55
mcp/src/openisle_mcp/schemas.py
Normal file
55
mcp/src/openisle_mcp/schemas.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Pydantic models describing tool inputs and outputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result entry."""
|
||||
|
||||
type: str = Field(description="Entity type for the result (post, user, tag, etc.).")
|
||||
id: Optional[int] = Field(default=None, description="Identifier of the matched entity.")
|
||||
text: Optional[str] = Field(default=None, description="Primary text associated with the result.")
|
||||
sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="subText",
|
||||
description="Secondary text, e.g. a username or excerpt.",
|
||||
)
|
||||
extra: Optional[str] = Field(default=None, description="Additional contextual information.")
|
||||
post_id: Optional[int] = Field(
|
||||
default=None,
|
||||
alias="postId",
|
||||
description="Associated post identifier when relevant.",
|
||||
)
|
||||
highlighted_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedText",
|
||||
description="Highlighted snippet of the primary text if available.",
|
||||
)
|
||||
highlighted_sub_text: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedSubText",
|
||||
description="Highlighted snippet of the secondary text if available.",
|
||||
)
|
||||
highlighted_extra: Optional[str] = Field(
|
||||
default=None,
|
||||
alias="highlightedExtra",
|
||||
description="Highlighted snippet of extra information if available.",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Structured response returned by the search tool."""
|
||||
|
||||
keyword: str = Field(description="The keyword that was searched.")
|
||||
total: int = Field(description="Total number of matches returned by the backend.")
|
||||
results: list[SearchResultItem] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered collection of search results.",
|
||||
)
|
||||
|
||||
51
mcp/src/openisle_mcp/search_client.py
Normal file
51
mcp/src/openisle_mcp/search_client.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""HTTP client helpers for talking to the OpenIsle backend search endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""Client for calling the OpenIsle search API."""
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = 10.0) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(base_url=self._base_url, timeout=self._timeout)
|
||||
return self._client
|
||||
|
||||
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||
"""Call the global search endpoint and return the parsed JSON payload."""
|
||||
|
||||
client = self._get_client()
|
||||
response = await client.get(
|
||||
"/api/search/global",
|
||||
params={"keyword": keyword},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list):
|
||||
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
||||
return [self._validate_entry(entry) for entry in payload]
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Dispose of the underlying HTTP client."""
|
||||
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
@staticmethod
|
||||
def _validate_entry(entry: Any) -> dict[str, Any]:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"Search entry must be an object, got: {type(entry)!r}")
|
||||
return entry
|
||||
98
mcp/src/openisle_mcp/server.py
Normal file
98
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Entry point for running the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import ValidationError
|
||||
from pydantic import Field as PydanticField
|
||||
|
||||
from .config import get_settings
|
||||
from .schemas import SearchResponse, SearchResultItem
|
||||
from .search_client import SearchClient
|
||||
|
||||
settings = get_settings()
|
||||
search_client = SearchClient(
|
||||
str(settings.backend_base_url), timeout=settings.request_timeout
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastMCP):
|
||||
"""Lifecycle hook that disposes shared resources when the server stops."""
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await search_client.aclose()
|
||||
|
||||
|
||||
app = FastMCP(
|
||||
name="openisle-mcp",
|
||||
instructions=(
|
||||
"Use this server to search OpenIsle posts, users, tags, categories, and comments "
|
||||
"via the global search endpoint."
|
||||
),
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
name="search",
|
||||
description="Perform a global search across OpenIsle resources.",
|
||||
structured_output=True,
|
||||
)
|
||||
async def search(
|
||||
keyword: Annotated[str, PydanticField(description="Keyword to search for.")],
|
||||
ctx: Context | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Call the OpenIsle global search endpoint and return structured results."""
|
||||
|
||||
sanitized = keyword.strip()
|
||||
if not sanitized:
|
||||
raise ValueError("Keyword must not be empty.")
|
||||
|
||||
try:
|
||||
raw_results = await search_client.global_search(sanitized)
|
||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||
message = (
|
||||
"OpenIsle backend returned HTTP "
|
||||
f"{exc.response.status_code} while searching for '{sanitized}'."
|
||||
)
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||
message = f"Unable to reach OpenIsle backend search service: {exc}."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
try:
|
||||
results = [SearchResultItem.model_validate(entry) for entry in raw_results]
|
||||
except ValidationError as exc:
|
||||
message = "Received malformed data from the OpenIsle backend search endpoint."
|
||||
if ctx is not None:
|
||||
await ctx.error(message)
|
||||
raise ValueError(message) from exc
|
||||
|
||||
if ctx is not None:
|
||||
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
|
||||
|
||||
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the MCP server using the configured transport."""
|
||||
|
||||
app.run(transport=settings.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution
|
||||
main()
|
||||
|
||||
@@ -100,10 +100,28 @@ server {
|
||||
# auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
}
|
||||
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_pass http://127.0.0.1:8084/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8085;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -8,11 +8,8 @@ server {
|
||||
listen 443 ssl;
|
||||
server_name staging.open-isle.com www.staging.open-isle.com;
|
||||
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
||||
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
@@ -40,59 +37,13 @@ server {
|
||||
add_header X-Upstream $upstream_addr always;
|
||||
}
|
||||
|
||||
# 1) 原生 WebSocket
|
||||
location ^~ /api/ws {
|
||||
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# 升级所需
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
||||
location ^~ /api/sockjs {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
||||
# proxy_hide_header X-Frame-Options;
|
||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
}
|
||||
|
||||
# ---------- API ----------
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8081/api/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
|
||||
proxy_set_header Host $host;
|
||||
@@ -109,7 +60,6 @@ server {
|
||||
proxy_cache_bypass 1;
|
||||
}
|
||||
|
||||
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
|
||||
location ^~ /websocket/ {
|
||||
proxy_pass http://127.0.0.1:8083/;
|
||||
proxy_http_version 1.1;
|
||||
@@ -130,4 +80,24 @@ server {
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
}
|
||||
location /mcp {
|
||||
proxy_pass http://127.0.0.1:8086;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user