mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 05:21:15 +08:00
Compare commits
36 Commits
feature/ui
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd6a3249d | ||
|
|
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 |
@@ -7,6 +7,15 @@ REDIS_PORT=6379
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
|
||||
# === MCP Server ===
|
||||
OPENISLE_MCP_TRANSPORT=http
|
||||
OPENISLE_MCP_HOST=0.0.0.0
|
||||
OPENISLE_MCP_PORT=8974
|
||||
OPENISLE_API_BASE_URL=http://springboot:8080
|
||||
OPENISLE_API_TIMEOUT=10
|
||||
OPENISLE_MCP_DEFAULT_LIMIT=20
|
||||
OPENISLE_MCP_SNIPPET_LENGTH=160
|
||||
|
||||
# === OpenSearch Configuration ===
|
||||
OPENSEARCH_PORT=9200
|
||||
OPENSEARCH_METRICS_PORT=9600
|
||||
|
||||
@@ -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 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
@@ -28,6 +28,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
|
||||
- 支持图片上传,默认使用腾讯云 COS 扩展
|
||||
- 默认头像使用 DiceBear Avatars,可通过 `AVATAR_STYLE` 和 `AVATAR_SIZE` 环境变量自定义主题和大小
|
||||
- 浏览器推送通知,离开网站也能及时收到提醒
|
||||
- 新增 Python MCP 搜索服务,方便 AI 助手通过统一协议检索社区内容
|
||||
|
||||
## 🌟 项目优势
|
||||
|
||||
|
||||
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}
|
||||
|
||||
@@ -36,7 +36,6 @@ 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 \
|
||||
|
||||
@@ -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,37 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
mcp-server:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||
env_file:
|
||||
- ${ENV_FILE:-../.env}
|
||||
environment:
|
||||
OPENISLE_API_BASE_URL: ${OPENISLE_API_BASE_URL:-http://springboot:8080}
|
||||
OPENISLE_API_TIMEOUT: ${OPENISLE_API_TIMEOUT:-10}
|
||||
OPENISLE_MCP_DEFAULT_LIMIT: ${OPENISLE_MCP_DEFAULT_LIMIT:-20}
|
||||
OPENISLE_MCP_SNIPPET_LENGTH: ${OPENISLE_MCP_SNIPPET_LENGTH:-160}
|
||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-http}
|
||||
OPENISLE_MCP_HOST: 0.0.0.0
|
||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8974}
|
||||
ports:
|
||||
- "${OPENISLE_MCP_PORT:-8974}:${OPENISLE_MCP_PORT:-8974}"
|
||||
depends_on:
|
||||
springboot:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
websocket-service:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
@@ -186,6 +236,10 @@ services:
|
||||
start_period: 60s
|
||||
networks:
|
||||
- openisle-network
|
||||
profiles:
|
||||
- dev
|
||||
- dev_local_backend
|
||||
- prod
|
||||
|
||||
frontend_dev:
|
||||
image: node:20
|
||||
@@ -208,6 +262,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 +302,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 +319,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 +365,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
mcp/Dockerfile
Normal file
27
mcp/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY src ./src
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install --no-cache-dir . \
|
||||
&& pip cache purge
|
||||
|
||||
ENV OPENISLE_MCP_TRANSPORT=http \
|
||||
OPENISLE_MCP_HOST=0.0.0.0 \
|
||||
OPENISLE_MCP_PORT=8974
|
||||
|
||||
EXPOSE 8974
|
||||
|
||||
ENTRYPOINT ["openisle-mcp"]
|
||||
51
mcp/README.md
Normal file
51
mcp/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# OpenIsle MCP Server
|
||||
|
||||
This package provides a Python implementation of a Model Context Protocol (MCP) server for OpenIsle. The server focuses on the community search APIs so that AI assistants and other MCP-aware clients can discover OpenIsle users, posts, categories, comments, and tags. Additional capabilities such as content creation tools can be layered on later without changing the transport or deployment model.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Implements the MCP tooling interface using [FastMCP](https://github.com/modelcontextprotocol/fastmcp).
|
||||
- 🔍 Exposes a `search` tool that proxies requests to the existing OpenIsle REST endpoints and normalises the response payload.
|
||||
- ⚙️ Configurable through environment variables for API base URL, timeout, result limits, and snippet size.
|
||||
- 🐳 Packaged with a Docker image so it can be launched alongside the other OpenIsle services.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `OPENISLE_API_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend REST API. |
|
||||
| `OPENISLE_API_TIMEOUT` | `10` | Timeout (in seconds) used when calling the backend search endpoints. |
|
||||
| `OPENISLE_MCP_DEFAULT_LIMIT` | `20` | Default maximum number of search results to return when the caller does not provide a limit. Use `0` or a negative number to disable limiting. |
|
||||
| `OPENISLE_MCP_SNIPPET_LENGTH` | `160` | Maximum length (in characters) of the normalised summary snippet returned by the MCP tool. |
|
||||
| `OPENISLE_MCP_TRANSPORT` | `stdio` | Transport used when running the server directly. Set to `http` when running inside Docker. |
|
||||
| `OPENISLE_MCP_HOST` | `127.0.0.1` | Bind host used when the transport is HTTP/SSE. |
|
||||
| `OPENISLE_MCP_PORT` | `8974` | Bind port used when the transport is HTTP/SSE. |
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
cd mcp
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
pip install -e .
|
||||
OPENISLE_API_BASE_URL=http://localhost:8080 OPENISLE_MCP_TRANSPORT=http openisle-mcp
|
||||
```
|
||||
|
||||
By default the server listens over stdio, which is useful when integrating with MCP-aware IDEs. When the `OPENISLE_MCP_TRANSPORT` variable is set to `http` the server will expose an HTTP transport on `OPENISLE_MCP_HOST:OPENISLE_MCP_PORT`.
|
||||
|
||||
## Docker image
|
||||
|
||||
The accompanying `Dockerfile` builds a minimal image that installs the package and starts the MCP server. The root Docker Compose manifest is configured to launch this service and connect it to the same internal network as the Spring Boot API so the MCP tools can reach the search endpoints.
|
||||
|
||||
## MCP tool contract
|
||||
|
||||
The `search` tool accepts the following arguments:
|
||||
|
||||
- `keyword` (string, required): Search phrase passed directly to the OpenIsle API.
|
||||
- `scope` (string, optional): One of `global`, `posts`, `posts_content`, `posts_title`, or `users`. Defaults to `global`.
|
||||
- `limit` (integer, optional): Overrides the default limit from `OPENISLE_MCP_DEFAULT_LIMIT`.
|
||||
|
||||
The tool returns a JSON object containing both the raw API response and a normalised representation with concise titles, subtitles, and snippets for each result.
|
||||
|
||||
Future tools (for example posting or moderation helpers) can be added within this package and exposed via additional decorators without changing the deployment setup.
|
||||
30
mcp/pyproject.toml
Normal file
30
mcp/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.25.0"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "openisle-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Model Context Protocol server exposing OpenIsle search functionality."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
authors = [{name = "OpenIsle Contributors"}]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.12.5",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic>=2.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
openisle-mcp = "openisle_mcp.server:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/openisle_mcp"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = [
|
||||
"src/openisle_mcp",
|
||||
"README.md",
|
||||
"pyproject.toml",
|
||||
]
|
||||
5
mcp/src/openisle_mcp/__init__.py
Normal file
5
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""OpenIsle MCP server package."""
|
||||
|
||||
from .server import main
|
||||
|
||||
__all__ = ["main"]
|
||||
218
mcp/src/openisle_mcp/client.py
Normal file
218
mcp/src/openisle_mcp/client.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""HTTP client wrappers for interacting with the OpenIsle backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import NormalizedSearchResult, SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
_TAG_RE = re.compile(r"<[^>]+>")
|
||||
_WHITESPACE_RE = re.compile(r"\s+")
|
||||
|
||||
|
||||
class SearchClient:
|
||||
"""High level client around the OpenIsle search API."""
|
||||
|
||||
_ENDPOINTS: dict[SearchScope, str] = {
|
||||
SearchScope.GLOBAL: "/api/search/global",
|
||||
SearchScope.POSTS: "/api/search/posts",
|
||||
SearchScope.POSTS_CONTENT: "/api/search/posts/content",
|
||||
SearchScope.POSTS_TITLE: "/api/search/posts/title",
|
||||
SearchScope.USERS: "/api/search/users",
|
||||
}
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.sanitized_base_url()
|
||||
self._timeout = settings.request_timeout
|
||||
self._default_limit = settings.default_limit
|
||||
self._snippet_length = settings.snippet_length
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
timeout=self._timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def endpoint_path(self, scope: SearchScope) -> str:
|
||||
return self._ENDPOINTS[scope]
|
||||
|
||||
def endpoint_url(self, scope: SearchScope) -> str:
|
||||
return f"{self._base_url}{self.endpoint_path(scope)}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
keyword: str,
|
||||
scope: SearchScope,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> SearchResponse:
|
||||
"""Execute a search request and normalise the results."""
|
||||
|
||||
keyword = keyword.strip()
|
||||
effective_limit = self._resolve_limit(limit)
|
||||
|
||||
if not keyword:
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=0,
|
||||
returned_results=0,
|
||||
normalized=[],
|
||||
raw=[],
|
||||
)
|
||||
|
||||
response = await self._client.get(
|
||||
self.endpoint_path(scope),
|
||||
params={"keyword": keyword},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if not isinstance(payload, list): # pragma: no cover - defensive programming
|
||||
raise ValueError("Search API did not return a JSON array")
|
||||
|
||||
total_results = len(payload)
|
||||
items = payload if effective_limit is None else payload[:effective_limit]
|
||||
normalized = [self._normalise_item(scope, item) for item in items]
|
||||
|
||||
return SearchResponse(
|
||||
keyword=keyword,
|
||||
scope=scope,
|
||||
endpoint=self.endpoint_url(scope),
|
||||
limit=effective_limit,
|
||||
total_results=total_results,
|
||||
returned_results=len(items),
|
||||
normalized=normalized,
|
||||
raw=items,
|
||||
)
|
||||
|
||||
def _resolve_limit(self, requested: int | None) -> int | None:
|
||||
value = requested if requested is not None else self._default_limit
|
||||
if value is None:
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
def _normalise_item(
|
||||
self,
|
||||
scope: SearchScope,
|
||||
item: Any,
|
||||
) -> NormalizedSearchResult:
|
||||
"""Normalise raw API objects into a consistent structure."""
|
||||
|
||||
if not isinstance(item, dict): # pragma: no cover - defensive programming
|
||||
return NormalizedSearchResult(type=scope.value, metadata={"raw": item})
|
||||
|
||||
if scope == SearchScope.GLOBAL:
|
||||
return self._normalise_global(item)
|
||||
if scope in {SearchScope.POSTS, SearchScope.POSTS_CONTENT, SearchScope.POSTS_TITLE}:
|
||||
return self._normalise_post(item)
|
||||
if scope == SearchScope.USERS:
|
||||
return self._normalise_user(item)
|
||||
return NormalizedSearchResult(type=scope.value, metadata=item)
|
||||
|
||||
def _normalise_global(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
highlights = {
|
||||
"title": item.get("highlightedText"),
|
||||
"subtitle": item.get("highlightedSubText"),
|
||||
"snippet": item.get("highlightedExtra"),
|
||||
}
|
||||
snippet_source = highlights.get("snippet") or item.get("extra")
|
||||
metadata = {
|
||||
"postId": item.get("postId"),
|
||||
"highlights": {k: v for k, v in highlights.items() if v},
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type=str(item.get("type", "result")),
|
||||
id=_safe_int(item.get("id")),
|
||||
title=highlights.get("title") or _safe_str(item.get("text")),
|
||||
subtitle=highlights.get("subtitle") or _safe_str(item.get("subText")),
|
||||
snippet=self._snippet(snippet_source),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, {}, [])},
|
||||
)
|
||||
|
||||
def _normalise_post(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
author = _safe_dict(item.get("author"))
|
||||
category = _safe_dict(item.get("category"))
|
||||
tags = [tag.get("name") for tag in _safe_iter(item.get("tags")) if isinstance(tag, dict)]
|
||||
metadata = {
|
||||
"author": author.get("username"),
|
||||
"category": category.get("name"),
|
||||
"tags": tags,
|
||||
"views": item.get("views"),
|
||||
"commentCount": item.get("commentCount"),
|
||||
"status": item.get("status"),
|
||||
"apiUrl": f"{self._base_url}/api/posts/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="post",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("title")),
|
||||
subtitle=_safe_str(category.get("name")),
|
||||
snippet=self._snippet(item.get("content")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _normalise_user(self, item: dict[str, Any]) -> NormalizedSearchResult:
|
||||
metadata = {
|
||||
"followers": item.get("followers"),
|
||||
"following": item.get("following"),
|
||||
"totalViews": item.get("totalViews"),
|
||||
"role": item.get("role"),
|
||||
"subscribed": item.get("subscribed"),
|
||||
"apiUrl": f"{self._base_url}/api/users/{item.get('id')}" if item.get("id") else None,
|
||||
}
|
||||
return NormalizedSearchResult(
|
||||
type="user",
|
||||
id=_safe_int(item.get("id")),
|
||||
title=_safe_str(item.get("username")),
|
||||
subtitle=_safe_str(item.get("email") or item.get("role")),
|
||||
snippet=self._snippet(item.get("introduction")),
|
||||
metadata={k: v for k, v in metadata.items() if v not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _snippet(self, value: Any) -> str | None:
|
||||
text = _safe_str(value)
|
||||
if not text:
|
||||
return None
|
||||
text = html.unescape(text)
|
||||
text = _TAG_RE.sub(" ", text)
|
||||
text = _WHITESPACE_RE.sub(" ", text).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) <= self._snippet_length:
|
||||
return text
|
||||
return text[: self._snippet_length - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError): # pragma: no cover - defensive
|
||||
return None
|
||||
|
||||
|
||||
def _safe_str(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _safe_iter(value: Any) -> Iterable[Any]:
|
||||
if isinstance(value, list | tuple | set):
|
||||
return value
|
||||
return []
|
||||
71
mcp/src/openisle_mcp/models.py
Normal file
71
mcp/src/openisle_mcp/models.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Shared models for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchScope(str, Enum):
|
||||
"""Supported search endpoints."""
|
||||
|
||||
GLOBAL = "global"
|
||||
POSTS = "posts"
|
||||
POSTS_CONTENT = "posts_content"
|
||||
POSTS_TITLE = "posts_title"
|
||||
USERS = "users"
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - convenience for logging
|
||||
return self.value
|
||||
|
||||
|
||||
class NormalizedSearchResult(BaseModel):
|
||||
"""Compact structure returned by the MCP search tool."""
|
||||
|
||||
type: str = Field(description="Entity type, e.g. user, post, comment.")
|
||||
id: int | None = Field(default=None, description="Primary identifier of the entity.")
|
||||
title: str | None = Field(default=None, description="Display title for the result.")
|
||||
subtitle: str | None = Field(default=None, description="Secondary line of context.")
|
||||
snippet: str | None = Field(default=None, description="Short summary of the result.")
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Additional attributes extracted from the API response.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Payload returned to MCP clients."""
|
||||
|
||||
keyword: str
|
||||
scope: SearchScope
|
||||
endpoint: str
|
||||
limit: int | None = Field(
|
||||
default=None,
|
||||
description="Result limit applied to the request. None means unlimited.",
|
||||
)
|
||||
total_results: int = Field(
|
||||
default=0,
|
||||
description="Total number of items returned by the OpenIsle API before limiting.",
|
||||
)
|
||||
returned_results: int = Field(
|
||||
default=0,
|
||||
description="Number of items returned to the MCP client after limiting.",
|
||||
)
|
||||
normalized: list[NormalizedSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Normalised representation of each search hit.",
|
||||
)
|
||||
raw: list[Any] = Field(
|
||||
default_factory=list,
|
||||
description="Raw response objects from the OpenIsle REST API.",
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
}
|
||||
95
mcp/src/openisle_mcp/server.py
Normal file
95
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Entrypoint for the OpenIsle MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastmcp import Context, FastMCP
|
||||
|
||||
from .client import SearchClient
|
||||
from .models import SearchResponse, SearchScope
|
||||
from .settings import Settings
|
||||
|
||||
__all__ = ["main"]
|
||||
|
||||
|
||||
def _create_lifespan(settings: Settings):
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastMCP):
|
||||
client = SearchClient(settings)
|
||||
setattr(app, "_search_client", client)
|
||||
try:
|
||||
yield {"client": client}
|
||||
finally:
|
||||
await client.aclose()
|
||||
if hasattr(app, "_search_client"):
|
||||
delattr(app, "_search_client")
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
_settings = Settings.from_env()
|
||||
|
||||
mcp = FastMCP(
|
||||
name="OpenIsle Search",
|
||||
version="0.1.0",
|
||||
instructions=(
|
||||
"Provides access to OpenIsle search endpoints for retrieving users, posts, "
|
||||
"comments, tags, and categories."
|
||||
),
|
||||
lifespan=_create_lifespan(_settings),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool("search")
|
||||
async def search(
|
||||
keyword: str,
|
||||
scope: SearchScope = SearchScope.GLOBAL,
|
||||
limit: int | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Perform a search against the OpenIsle backend."""
|
||||
|
||||
client = _resolve_client(ctx)
|
||||
try:
|
||||
response: SearchResponse = await client.search(keyword=keyword, scope=scope, limit=limit)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"OpenIsle search request failed: {exc}".rstrip()
|
||||
raise RuntimeError(message) from exc
|
||||
|
||||
payload = response.model_dump()
|
||||
payload["transport"] = {
|
||||
"scope": scope.value,
|
||||
"endpoint": client.endpoint_url(scope),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_client(ctx: Context | None) -> SearchClient:
|
||||
app = ctx.fastmcp if ctx is not None else mcp
|
||||
client = getattr(app, "_search_client", None)
|
||||
if client is None:
|
||||
raise RuntimeError("Search client is not initialised; lifespan hook not executed")
|
||||
return client
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entrypoint."""
|
||||
|
||||
transport = os.getenv("OPENISLE_MCP_TRANSPORT", "stdio").strip().lower()
|
||||
show_banner = os.getenv("OPENISLE_MCP_SHOW_BANNER", "true").lower() in {"1", "true", "yes"}
|
||||
run_kwargs: dict[str, Any] = {"show_banner": show_banner}
|
||||
|
||||
if transport in {"http", "sse", "streamable-http"}:
|
||||
host = os.getenv("OPENISLE_MCP_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("OPENISLE_MCP_PORT", "8974"))
|
||||
run_kwargs.update({"host": host, "port": port})
|
||||
|
||||
mcp.run(transport=transport, **run_kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - manual execution guard
|
||||
main()
|
||||
102
mcp/src/openisle_mcp/settings.py
Normal file
102
mcp/src/openisle_mcp/settings.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Environment configuration for the MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Runtime configuration sourced from environment variables."""
|
||||
|
||||
api_base_url: str = Field(
|
||||
default="http://springboot:8080",
|
||||
description="Base URL of the OpenIsle backend REST API.",
|
||||
)
|
||||
request_timeout: float = Field(
|
||||
default=10.0,
|
||||
description="Timeout in seconds for outgoing HTTP requests.",
|
||||
ge=0.1,
|
||||
)
|
||||
default_limit: int = Field(
|
||||
default=20,
|
||||
description="Default maximum number of results returned by the search tool.",
|
||||
)
|
||||
snippet_length: int = Field(
|
||||
default=160,
|
||||
description="Maximum length for the normalised snippet field.",
|
||||
ge=40,
|
||||
)
|
||||
|
||||
model_config = {
|
||||
"extra": "ignore",
|
||||
"validate_assignment": True,
|
||||
}
|
||||
|
||||
@field_validator("api_base_url", mode="before")
|
||||
@classmethod
|
||||
def _strip_trailing_slash(cls, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if value.endswith("/"):
|
||||
return value.rstrip("/")
|
||||
return value
|
||||
|
||||
@field_validator("default_limit", mode="before")
|
||||
@classmethod
|
||||
def _parse_default_limit(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("default_limit must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("snippet_length", mode="before")
|
||||
@classmethod
|
||||
def _parse_snippet_length(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("snippet_length must be an integer") from exc
|
||||
return value
|
||||
|
||||
@field_validator("request_timeout", mode="before")
|
||||
@classmethod
|
||||
def _parse_timeout(cls, value: Any) -> Any:
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError as exc: # pragma: no cover - defensive
|
||||
raise ValueError("request_timeout must be a number") from exc
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
"""Build a settings object using environment variables."""
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
mapping = {
|
||||
"api_base_url": "OPENISLE_API_BASE_URL",
|
||||
"request_timeout": "OPENISLE_API_TIMEOUT",
|
||||
"default_limit": "OPENISLE_MCP_DEFAULT_LIMIT",
|
||||
"snippet_length": "OPENISLE_MCP_SNIPPET_LENGTH",
|
||||
}
|
||||
for field, env_key in mapping.items():
|
||||
value = os.getenv(env_key)
|
||||
if value is not None and value != "":
|
||||
data[field] = value
|
||||
try:
|
||||
return cls.model_validate(data)
|
||||
except ValidationError as exc: # pragma: no cover - validation errors surface early
|
||||
raise ValueError(
|
||||
"Invalid MCP settings derived from environment variables"
|
||||
) from exc
|
||||
|
||||
def sanitized_base_url(self) -> str:
|
||||
"""Return the API base URL without trailing slashes."""
|
||||
|
||||
return self.api_base_url.rstrip("/")
|
||||
Reference in New Issue
Block a user