mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-09 00:21:13 +08:00
Compare commits
77 Commits
codex/add-
...
codex/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e585100625 | ||
|
|
e94471b53e | ||
|
|
997dacdbe6 | ||
|
|
c01349a436 | ||
|
|
4cf48f9157 | ||
|
|
796afbe612 | ||
|
|
dca14390ca | ||
|
|
39875acd35 | ||
|
|
62edc75735 | ||
|
|
26ca9fc916 | ||
|
|
cad70c23b3 | ||
|
|
016276dbc3 | ||
|
|
bd2d6e7485 | ||
|
|
df59a9fd4b | ||
|
|
2e70a3d273 | ||
|
|
3dc6935d19 | ||
|
|
779bb2db78 | ||
|
|
b3b0b194a3 | ||
|
|
e21b2f42d2 | ||
|
|
05a5acee7e | ||
|
|
755982098b | ||
|
|
af24263c0a | ||
|
|
8fd268bd11 | ||
|
|
a24bd81942 | ||
|
|
8a008a090a | ||
|
|
5dfb69e636 | ||
|
|
499069573e | ||
|
|
636912941a | ||
|
|
bdcc1488b9 | ||
|
|
d33bd233af | ||
|
|
efe4b97d83 | ||
|
|
8a256e167d | ||
|
|
9c5a49a47f | ||
|
|
2271bbbd1d | ||
|
|
d6470e04fc | ||
|
|
4db35a4531 | ||
|
|
1906ffd8aa | ||
|
|
426884385f | ||
|
|
8193c92c91 | ||
|
|
90649b422d | ||
|
|
67efb64ccc | ||
|
|
23d8eafc08 | ||
|
|
d1cc16e31e | ||
|
|
0f1c45b155 | ||
|
|
8ed11df99c | ||
|
|
458b125834 | ||
|
|
971a3d36c6 | ||
|
|
e5d66d73cb | ||
|
|
a9608cc706 | ||
|
|
232f40151b | ||
|
|
3b3f99754d | ||
|
|
e14566ee66 | ||
|
|
892312c6d4 | ||
|
|
dfb31771ff | ||
|
|
bf7df629cc | ||
|
|
f17b644a9b | ||
|
|
61f8fa4bb7 | ||
|
|
43929bcdc5 | ||
|
|
6aecb4f583 | ||
|
|
0d2e6a9505 | ||
|
|
b2d70b9bde | ||
|
|
d914579d64 | ||
|
|
8643446d8b | ||
|
|
2db958f8c9 | ||
|
|
fa29d255c9 | ||
|
|
b3fa5e2bef | ||
|
|
a7ef4380d8 | ||
|
|
39d954d98a | ||
|
|
ce04570efb | ||
|
|
215c7077d5 | ||
|
|
a68c925c68 | ||
|
|
4f248e8a71 | ||
|
|
277883f9d9 | ||
|
|
e9e996f291 | ||
|
|
c9854e1840 | ||
|
|
3da5d24488 | ||
|
|
76962d6d1c |
@@ -2,6 +2,7 @@
|
|||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
WEBSOCKET_PORT=8082
|
WEBSOCKET_PORT=8082
|
||||||
|
OPENISLE_MCP_PORT=8085
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
RABBITMQ_PORT=5672
|
RABBITMQ_PORT=5672
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
- [前置工作](#前置工作)
|
- [前置工作](#前置工作)
|
||||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||||
|
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
||||||
- [启动后端服务](#启动后端服务)
|
- [启动后端服务](#启动后端服务)
|
||||||
- [本地 IDEA](#本地-idea)
|
- [本地 IDEA](#本地-idea)
|
||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
@@ -39,13 +40,6 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||||
2. 启动 Dev Profile:
|
2. 启动 Dev Profile:
|
||||||
```shell
|
|
||||||
docker compose \
|
|
||||||
-f docker/docker-compose.yaml \
|
|
||||||
--env-file .env \
|
|
||||||
--profile dev build
|
|
||||||
```
|
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker/docker-compose.yaml \
|
-f docker/docker-compose.yaml \
|
||||||
@@ -53,8 +47,8 @@ cd OpenIsle
|
|||||||
--profile dev up -d
|
--profile dev up -d
|
||||||
```
|
```
|
||||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||||
|
修改前端代码,页面会热更新。
|
||||||
修改代码后,可以强制重新创建所有容器,执行:
|
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -81,6 +75,41 @@ cd OpenIsle
|
|||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||||
|
|
||||||
|
<a id="dev-dev_local_backend-guide"></a>
|
||||||
|
|
||||||
|
### 🧭 dev 与 dev_local_backend 巡航指南
|
||||||
|
|
||||||
|
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose \
|
||||||
|
-f docker/docker-compose.yaml \
|
||||||
|
--env-file .env \
|
||||||
|
--profile dev_local_backend up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
|
||||||
|
|
||||||
|
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
|
||||||
|
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
|
||||||
|
|
||||||
|
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||||
|
# 或者
|
||||||
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
|
||||||
|
```
|
||||||
|
|
||||||
|
常见小贴士:
|
||||||
|
|
||||||
|
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
|
||||||
|
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
|
||||||
|
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|
||||||
启动后端服务有多种方式,选择一种即可。
|
启动后端服务有多种方式,选择一种即可。
|
||||||
@@ -110,6 +139,17 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`:
|
||||||
|
>
|
||||||
|
> ```ini
|
||||||
|
> MYSQL_HOST=localhost
|
||||||
|
> REDIS_HOST=localhost
|
||||||
|
> RABBITMQ_HOST=localhost
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`,IDEA 将尝试解析容器网络内的别名而导致连接失败。
|
||||||
|
|
||||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
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 ===
|
||||||
REDIS_HOST=<Redis 地址>
|
REDIS_HOST=<Redis 地址>
|
||||||
REDIS_PORT=<Redis 端口>
|
REDIS_PORT=<Redis 端口>
|
||||||
|
REDIS_PASS=<Redis 密码>
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.dto.CommentContextDto;
|
||||||
import com.openisle.dto.CommentDto;
|
import com.openisle.dto.CommentDto;
|
||||||
import com.openisle.dto.CommentRequest;
|
import com.openisle.dto.CommentRequest;
|
||||||
import com.openisle.dto.PostChangeLogDto;
|
import com.openisle.dto.PostChangeLogDto;
|
||||||
import com.openisle.dto.TimelineItemDto;
|
import com.openisle.dto.TimelineItemDto;
|
||||||
import com.openisle.mapper.CommentMapper;
|
import com.openisle.mapper.CommentMapper;
|
||||||
import com.openisle.mapper.PostChangeLogMapper;
|
import com.openisle.mapper.PostChangeLogMapper;
|
||||||
|
import com.openisle.mapper.PostMapper;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
@@ -40,6 +42,7 @@ public class CommentController {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService changeLogService;
|
private final PostChangeLogService changeLogService;
|
||||||
private final PostChangeLogMapper postChangeLogMapper;
|
private final PostChangeLogMapper postChangeLogMapper;
|
||||||
|
private final PostMapper postMapper;
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
private boolean captchaEnabled;
|
private boolean captchaEnabled;
|
||||||
@@ -184,6 +187,37 @@ public class CommentController {
|
|||||||
return itemDtoList;
|
return itemDtoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/comments/{commentId}/context")
|
||||||
|
@Operation(
|
||||||
|
summary = "Comment context",
|
||||||
|
description = "Get a comment along with its previous comments and related post"
|
||||||
|
)
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Comment context",
|
||||||
|
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
||||||
|
)
|
||||||
|
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
||||||
|
log.debug("getCommentContext called for comment {}", commentId);
|
||||||
|
Comment comment = commentService.getComment(commentId);
|
||||||
|
CommentContextDto dto = new CommentContextDto();
|
||||||
|
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
||||||
|
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
||||||
|
dto.setPreviousComments(
|
||||||
|
commentService
|
||||||
|
.getCommentsBefore(comment)
|
||||||
|
.stream()
|
||||||
|
.map(commentMapper::toDtoWithReplies)
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
log.debug(
|
||||||
|
"getCommentContext returning {} previous comments for comment {}",
|
||||||
|
dto.getPreviousComments().size(),
|
||||||
|
commentId
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@DeleteMapping("/comments/{id}")
|
@DeleteMapping("/comments/{id}")
|
||||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public class PostController {
|
|||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds(),
|
req.getTagIds(),
|
||||||
req.getType(),
|
req.getType(),
|
||||||
|
req.getPostVisibleScopeType(),
|
||||||
req.getPrizeDescription(),
|
req.getPrizeDescription(),
|
||||||
req.getPrizeIcon(),
|
req.getPrizeIcon(),
|
||||||
req.getPrizeCount(),
|
req.getPrizeCount(),
|
||||||
@@ -73,7 +74,9 @@ public class PostController {
|
|||||||
req.getStartTime(),
|
req.getStartTime(),
|
||||||
req.getEndTime(),
|
req.getEndTime(),
|
||||||
req.getOptions(),
|
req.getOptions(),
|
||||||
req.getMultiple()
|
req.getMultiple(),
|
||||||
|
req.getProposedName(),
|
||||||
|
req.getProposalDescription()
|
||||||
);
|
);
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
@@ -101,7 +104,8 @@ public class PostController {
|
|||||||
req.getCategoryId(),
|
req.getCategoryId(),
|
||||||
req.getTitle(),
|
req.getTitle(),
|
||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds()
|
req.getTagIds(),
|
||||||
|
req.getPostVisibleScopeType()
|
||||||
);
|
);
|
||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||||
}
|
}
|
||||||
@@ -220,6 +224,26 @@ public class PostController {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recent")
|
||||||
|
@Operation(
|
||||||
|
summary = "Recent posts",
|
||||||
|
description = "List posts created within the specified number of minutes"
|
||||||
|
)
|
||||||
|
@ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "Recent posts",
|
||||||
|
content = @Content(
|
||||||
|
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
|
||||||
|
return postService
|
||||||
|
.listRecentPosts(minutes)
|
||||||
|
.stream()
|
||||||
|
.map(postMapper::toSummaryDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/ranking")
|
@GetMapping("/ranking")
|
||||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||||
@ApiResponse(
|
@ApiResponse(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.openisle.controller;
|
||||||
|
|
||||||
|
import com.openisle.dto.DonationRequest;
|
||||||
|
import com.openisle.dto.DonationResponse;
|
||||||
|
import com.openisle.service.PointService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/posts/{postId}/donations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PostDonationController {
|
||||||
|
|
||||||
|
private final PointService pointService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "List donations", description = "Get recent donations for a post")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Donation summary")
|
||||||
|
public DonationResponse list(@PathVariable Long postId) {
|
||||||
|
return pointService.getPostDonations(postId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@SecurityRequirement(name = "JWT")
|
||||||
|
@Operation(summary = "Donate", description = "Donate points to the post author")
|
||||||
|
@ApiResponse(responseCode = "200", description = "Donation result")
|
||||||
|
public DonationResponse donate(
|
||||||
|
@PathVariable Long postId,
|
||||||
|
@RequestBody DonationRequest req,
|
||||||
|
Authentication auth
|
||||||
|
) {
|
||||||
|
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO representing the context of a comment including its post and previous comments.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class CommentContextDto {
|
||||||
|
|
||||||
|
private PostSummaryDto post;
|
||||||
|
private CommentDto targetComment;
|
||||||
|
private List<CommentDto> previousComments;
|
||||||
|
}
|
||||||
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DonationDto {
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
private String username;
|
||||||
|
private String avatar;
|
||||||
|
private int amount;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DonationRequest {
|
||||||
|
|
||||||
|
private int amount;
|
||||||
|
}
|
||||||
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package com.openisle.dto;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class DonationResponse {
|
||||||
|
|
||||||
|
private int totalAmount;
|
||||||
|
private List<DonationDto> donations = new ArrayList<>();
|
||||||
|
private Integer balance;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import com.openisle.model.PostChangeType;
|
import com.openisle.model.PostChangeType;
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -29,4 +30,7 @@ public class PostChangeLogDto {
|
|||||||
private LocalDateTime newPinnedAt;
|
private LocalDateTime newPinnedAt;
|
||||||
private Boolean oldFeatured;
|
private Boolean oldFeatured;
|
||||||
private Boolean newFeatured;
|
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 com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +21,7 @@ public class PostRequest {
|
|||||||
|
|
||||||
// optional for lottery posts
|
// optional for lottery posts
|
||||||
private PostType type;
|
private PostType type;
|
||||||
|
private PostVisibleScopeType postVisibleScopeType;
|
||||||
private String prizeDescription;
|
private String prizeDescription;
|
||||||
private String prizeIcon;
|
private String prizeIcon;
|
||||||
private Integer prizeCount;
|
private Integer prizeCount;
|
||||||
@@ -28,4 +31,8 @@ public class PostRequest {
|
|||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
private Boolean multiple;
|
private Boolean multiple;
|
||||||
|
|
||||||
|
// fields for category proposal posts
|
||||||
|
private String proposedName;
|
||||||
|
private String proposalDescription;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
|
|||||||
import com.openisle.model.PostType;
|
import com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.openisle.model.PostVisibleScopeType;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,4 +36,5 @@ public class PostSummaryDto {
|
|||||||
private PollDto poll;
|
private PollDto poll;
|
||||||
private boolean rssExcluded;
|
private boolean rssExcluded;
|
||||||
private boolean closed;
|
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,11 @@ public class PostChangeLogMapper {
|
|||||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||||
dto.setOldFeatured(f.isOldFeatured());
|
dto.setOldFeatured(f.isOldFeatured());
|
||||||
dto.setNewFeatured(f.isNewFeatured());
|
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());
|
||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import com.openisle.dto.LotteryDto;
|
|||||||
import com.openisle.dto.PollDto;
|
import com.openisle.dto.PollDto;
|
||||||
import com.openisle.dto.PostDetailDto;
|
import com.openisle.dto.PostDetailDto;
|
||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
|
import com.openisle.dto.ProposalDto;
|
||||||
import com.openisle.dto.ReactionDto;
|
import com.openisle.dto.ReactionDto;
|
||||||
|
import com.openisle.model.CategoryProposalPost;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.model.LotteryPost;
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.model.PollPost;
|
import com.openisle.model.PollPost;
|
||||||
@@ -73,6 +75,7 @@ public class PostMapper {
|
|||||||
dto.setPinnedAt(post.getPinnedAt());
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||||
dto.setClosed(post.isClosed());
|
dto.setClosed(post.isClosed());
|
||||||
|
dto.setVisibleScope(post.getVisibleScope());
|
||||||
|
|
||||||
List<ReactionDto> reactions = reactionService
|
List<ReactionDto> reactions = reactionService
|
||||||
.getReactionsForPost(post.getId())
|
.getReactionsForPost(post.getId())
|
||||||
@@ -113,26 +116,40 @@ public class PostMapper {
|
|||||||
dto.setLottery(l);
|
dto.setLottery(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post instanceof PollPost pp) {
|
if (post instanceof CategoryProposalPost cp) {
|
||||||
PollDto p = new PollDto();
|
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
|
||||||
p.setOptions(pp.getOptions());
|
proposalDto.setProposalStatus(cp.getProposalStatus());
|
||||||
p.setVotes(pp.getVotes());
|
proposalDto.setProposedName(cp.getProposedName());
|
||||||
p.setEndTime(pp.getEndTime());
|
proposalDto.setDescription(cp.getDescription());
|
||||||
p.setParticipants(
|
proposalDto.setApproveThreshold(cp.getApproveThreshold());
|
||||||
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
proposalDto.setQuorum(cp.getQuorum());
|
||||||
);
|
proposalDto.setStartAt(cp.getStartAt());
|
||||||
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
proposalDto.setResultSnapshot(cp.getResultSnapshot());
|
||||||
.findByPostId(pp.getId())
|
proposalDto.setRejectReason(cp.getRejectReason());
|
||||||
.stream()
|
dto.setPoll(proposalDto);
|
||||||
.collect(
|
} else if (post instanceof PollPost pp) {
|
||||||
Collectors.groupingBy(
|
dto.setPoll(buildPollDto(pp, new PollDto()));
|
||||||
PollVote::getOptionIndex,
|
|
||||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
p.setOptionParticipants(optionParticipants);
|
|
||||||
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
|
||||||
dto.setPoll(p);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private PollDto buildPollDto(PollPost pollPost, PollDto target) {
|
||||||
|
target.setOptions(pollPost.getOptions());
|
||||||
|
target.setVotes(pollPost.getVotes());
|
||||||
|
target.setEndTime(pollPost.getEndTime());
|
||||||
|
target.setParticipants(
|
||||||
|
pollPost.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
||||||
|
.findByPostId(pollPost.getId())
|
||||||
|
.stream()
|
||||||
|
.collect(
|
||||||
|
Collectors.groupingBy(
|
||||||
|
PollVote::getOptionIndex,
|
||||||
|
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
target.setOptionParticipants(optionParticipants);
|
||||||
|
target.setMultiple(Boolean.TRUE.equals(pollPost.getMultiple()));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Index;
|
||||||
|
import jakarta.persistence.PrimaryKeyJoinColumn;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specialized post type used for proposing new categories.
|
||||||
|
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "category_proposal_posts",
|
||||||
|
indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
|
||||||
|
)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@PrimaryKeyJoinColumn(name = "post_id")
|
||||||
|
public class CategoryProposalPost extends PollPost {
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false)
|
||||||
|
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
|
||||||
|
|
||||||
|
@Column(name = "proposed_name", nullable = false, unique = true)
|
||||||
|
private String proposedName;
|
||||||
|
|
||||||
|
@Column(name = "description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
// Approval threshold as percentage (0-100), default 60
|
||||||
|
@Column(name = "approve_threshold", nullable = false)
|
||||||
|
private int approveThreshold = 60;
|
||||||
|
|
||||||
|
// Minimum number of participants required to meet quorum
|
||||||
|
@Column(name = "quorum", nullable = false)
|
||||||
|
private int quorum = 10;
|
||||||
|
|
||||||
|
// Optional voting start time (end time inherited from PollPost)
|
||||||
|
@Column(name = "start_at")
|
||||||
|
private LocalDateTime startAt;
|
||||||
|
|
||||||
|
// Snapshot of poll results at finalization (e.g., JSON)
|
||||||
|
@Column(name = "result_snapshot", columnDefinition = "TEXT")
|
||||||
|
private String resultSnapshot;
|
||||||
|
|
||||||
|
// Reason when proposal is rejected
|
||||||
|
@Column(name = "reject_reason")
|
||||||
|
private String rejectReason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
public enum CategoryProposalStatus {
|
||||||
|
PENDING,
|
||||||
|
APPROVED,
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -46,8 +46,14 @@ public enum NotificationType {
|
|||||||
POLL_RESULT_OWNER,
|
POLL_RESULT_OWNER,
|
||||||
/** A poll you participated in has concluded */
|
/** A poll you participated in has concluded */
|
||||||
POLL_RESULT_PARTICIPANT,
|
POLL_RESULT_PARTICIPANT,
|
||||||
|
/** Your category proposal has concluded */
|
||||||
|
CATEGORY_PROPOSAL_RESULT_OWNER,
|
||||||
|
/** A category proposal you participated in has concluded */
|
||||||
|
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
||||||
/** Your post was featured */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
|
/** Someone donated to your post */
|
||||||
|
DONATION,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION,
|
MENTION,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ public enum PointHistoryType {
|
|||||||
REDEEM,
|
REDEEM,
|
||||||
LOTTERY_JOIN,
|
LOTTERY_JOIN,
|
||||||
LOTTERY_REWARD,
|
LOTTERY_REWARD,
|
||||||
|
DONATE_SENT,
|
||||||
|
DONATE_RECEIVED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ public class Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PostType type = PostType.NORMAL;
|
private PostType type = PostType.NORMAL;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean closed = false;
|
private boolean closed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ public enum PostChangeType {
|
|||||||
CLOSED,
|
CLOSED,
|
||||||
PINNED,
|
PINNED,
|
||||||
FEATURED,
|
FEATURED,
|
||||||
|
VISIBLE_SCOPE,
|
||||||
VOTE_RESULT,
|
VOTE_RESULT,
|
||||||
LOTTERY_RESULT,
|
LOTTERY_RESULT,
|
||||||
|
DONATE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.openisle.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@Entity
|
||||||
|
@Table(name = "post_donate_change_logs")
|
||||||
|
public class PostDonateChangeLog extends PostChangeLog {
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int amount;
|
||||||
|
}
|
||||||
@@ -4,4 +4,5 @@ public enum PostType {
|
|||||||
NORMAL,
|
NORMAL,
|
||||||
LOTTERY,
|
LOTTERY,
|
||||||
POLL,
|
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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -10,6 +11,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||||
|
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||||
|
Post post,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
);
|
||||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package com.openisle.repository;
|
|||||||
|
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.PointHistory;
|
import com.openisle.model.PointHistory;
|
||||||
|
import com.openisle.model.PointHistoryType;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||||
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
|||||||
List<PointHistory> findByComment(Comment comment);
|
List<PointHistory> findByComment(Comment comment);
|
||||||
|
|
||||||
List<PointHistory> findByPost(Post post);
|
List<PointHistory> findByPost(Post post);
|
||||||
|
|
||||||
|
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
|
||||||
|
)
|
||||||
|
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||||
|
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||||
|
PostStatus status,
|
||||||
|
LocalDateTime createdAt
|
||||||
|
);
|
||||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||||
User author,
|
User author,
|
||||||
PostStatus status,
|
PostStatus status,
|
||||||
|
|||||||
@@ -266,6 +266,27 @@ public class CommentService {
|
|||||||
return replies;
|
return replies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Comment getComment(Long commentId) {
|
||||||
|
log.debug("getComment called for id {}", commentId);
|
||||||
|
return commentRepository
|
||||||
|
.findById(commentId)
|
||||||
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Comment> getCommentsBefore(Comment comment) {
|
||||||
|
log.debug("getCommentsBefore called for comment {}", comment.getId());
|
||||||
|
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
||||||
|
comment.getPost(),
|
||||||
|
comment.getCreatedAt()
|
||||||
|
);
|
||||||
|
log.debug(
|
||||||
|
"getCommentsBefore returning {} comments for comment {}",
|
||||||
|
comments.size(),
|
||||||
|
comment.getId()
|
||||||
|
);
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||||
User user = userRepository
|
User user = userRepository
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
|
import com.openisle.dto.DonationDto;
|
||||||
|
import com.openisle.dto.DonationResponse;
|
||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
@@ -8,8 +10,10 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -20,6 +24,8 @@ public class PointService {
|
|||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final PostChangeLogService postChangeLogService;
|
||||||
|
|
||||||
public int awardForPost(String userName, Long postId) {
|
public int awardForPost(String userName, Long postId) {
|
||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
@@ -272,4 +278,95 @@ public class PointService {
|
|||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
return recalculateUserPoints(user);
|
return recalculateUserPoints(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
|
||||||
|
if (amount <= 0) {
|
||||||
|
throw new FieldException("amount", "打赏积分必须大于0");
|
||||||
|
}
|
||||||
|
User donor = userRepository.findByUsername(donorName).orElseThrow();
|
||||||
|
Post post = postRepository.findById(postId).orElseThrow();
|
||||||
|
User author = post.getAuthor();
|
||||||
|
if (author.getId().equals(donor.getId())) {
|
||||||
|
throw new FieldException("post", "不能给自己打赏");
|
||||||
|
}
|
||||||
|
if (donor.getPoint() < amount) {
|
||||||
|
throw new FieldException("point", "积分不足");
|
||||||
|
}
|
||||||
|
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
|
||||||
|
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
|
||||||
|
notificationService.createNotification(
|
||||||
|
author,
|
||||||
|
NotificationType.DONATION,
|
||||||
|
post,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
donor,
|
||||||
|
null,
|
||||||
|
String.valueOf(amount)
|
||||||
|
);
|
||||||
|
postChangeLogService.recordDonation(post, donor, amount);
|
||||||
|
DonationResponse response = buildDonationResponse(post);
|
||||||
|
response.setBalance(donor.getPoint());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DonationResponse getPostDonations(Long postId) {
|
||||||
|
Post post = postRepository.findById(postId).orElseThrow();
|
||||||
|
return buildDonationResponse(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DonationResponse buildDonationResponse(Post post) {
|
||||||
|
List<PointHistory> histories =
|
||||||
|
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
|
||||||
|
post,
|
||||||
|
PointHistoryType.DONATE_RECEIVED
|
||||||
|
);
|
||||||
|
List<DonationDto> donations = histories
|
||||||
|
.stream()
|
||||||
|
.collect(Collectors.collectingAndThen(Collectors.toMap(
|
||||||
|
history -> {
|
||||||
|
User donor = history.getFromUser();
|
||||||
|
if (donor != null && donor.getId() != null) {
|
||||||
|
return "user:" + donor.getId();
|
||||||
|
}
|
||||||
|
return "history:" + history.getId();
|
||||||
|
},
|
||||||
|
history -> {
|
||||||
|
DonationDto dto = new DonationDto();
|
||||||
|
User donor = history.getFromUser();
|
||||||
|
if (donor != null) {
|
||||||
|
dto.setUserId(donor.getId());
|
||||||
|
dto.setUsername(donor.getUsername());
|
||||||
|
dto.setAvatar(donor.getAvatar());
|
||||||
|
}
|
||||||
|
dto.setAmount(history.getAmount());
|
||||||
|
dto.setCreatedAt(history.getCreatedAt());
|
||||||
|
return dto;
|
||||||
|
},
|
||||||
|
(left, right) -> {
|
||||||
|
left.setAmount(left.getAmount() + right.getAmount());
|
||||||
|
if (
|
||||||
|
left.getCreatedAt() == null ||
|
||||||
|
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
|
||||||
|
) {
|
||||||
|
left.setCreatedAt(right.getCreatedAt());
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
},
|
||||||
|
java.util.LinkedHashMap::new
|
||||||
|
), map -> new java.util.ArrayList<>(map.values())));
|
||||||
|
Long total = pointHistoryRepository.sumAmountByPostAndType(
|
||||||
|
post,
|
||||||
|
PointHistoryType.DONATE_RECEIVED
|
||||||
|
);
|
||||||
|
int safeTotal = 0;
|
||||||
|
if (total != null) {
|
||||||
|
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
|
||||||
|
}
|
||||||
|
DonationResponse response = new DonationResponse();
|
||||||
|
response.setDonations(donations);
|
||||||
|
response.setTotalAmount(safeTotal);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,21 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
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) {
|
public void recordVoteResult(Post post) {
|
||||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||||
log.setPost(post);
|
log.setPost(post);
|
||||||
@@ -115,6 +130,15 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
logRepository.save(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void recordDonation(Post post, User donor, int amount) {
|
||||||
|
PostDonateChangeLog log = new PostDonateChangeLog();
|
||||||
|
log.setPost(post);
|
||||||
|
log.setUser(donor);
|
||||||
|
log.setType(PostChangeType.DONATE);
|
||||||
|
log.setAmount(amount);
|
||||||
|
logRepository.save(log);
|
||||||
|
}
|
||||||
|
|
||||||
public void deleteLogsForPost(Post post) {
|
public void deleteLogsForPost(Post post) {
|
||||||
logRepository.deleteByPost(post);
|
logRepository.deleteByPost(post);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
|
import com.openisle.exception.NotFoundException;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import com.openisle.mapper.PostMapper;
|
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
|
import com.openisle.repository.CategoryProposalPostRepository;
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import com.openisle.repository.CommentRepository;
|
import com.openisle.repository.CommentRepository;
|
||||||
import com.openisle.repository.LotteryPostRepository;
|
import com.openisle.repository.LotteryPostRepository;
|
||||||
@@ -21,7 +22,6 @@ import com.openisle.service.EmailSender;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
@@ -32,7 +32,6 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -54,6 +53,7 @@ public class PostService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final LotteryPostRepository lotteryPostRepository;
|
private final LotteryPostRepository lotteryPostRepository;
|
||||||
private final PollPostRepository pollPostRepository;
|
private final PollPostRepository pollPostRepository;
|
||||||
|
private final CategoryProposalPostRepository categoryProposalPostRepository;
|
||||||
private final PollVoteRepository pollVoteRepository;
|
private final PollVoteRepository pollVoteRepository;
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -71,11 +71,17 @@ public class PostService {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService postChangeLogService;
|
private final PostChangeLogService postChangeLogService;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final CategoryService categoryService;
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
|
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
|
||||||
|
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
|
||||||
|
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
|
||||||
|
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@@ -89,6 +95,7 @@ public class PostService {
|
|||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
LotteryPostRepository lotteryPostRepository,
|
LotteryPostRepository lotteryPostRepository,
|
||||||
PollPostRepository pollPostRepository,
|
PollPostRepository pollPostRepository,
|
||||||
|
CategoryProposalPostRepository categoryProposalPostRepository,
|
||||||
PollVoteRepository pollVoteRepository,
|
PollVoteRepository pollVoteRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
SubscriptionService subscriptionService,
|
SubscriptionService subscriptionService,
|
||||||
@@ -107,7 +114,8 @@ public class PostService {
|
|||||||
PointHistoryRepository pointHistoryRepository,
|
PointHistoryRepository pointHistoryRepository,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||||
RedisTemplate redisTemplate,
|
RedisTemplate redisTemplate,
|
||||||
SearchIndexEventPublisher searchIndexEventPublisher
|
SearchIndexEventPublisher searchIndexEventPublisher,
|
||||||
|
CategoryService categoryService
|
||||||
) {
|
) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -115,6 +123,7 @@ public class PostService {
|
|||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.lotteryPostRepository = lotteryPostRepository;
|
this.lotteryPostRepository = lotteryPostRepository;
|
||||||
this.pollPostRepository = pollPostRepository;
|
this.pollPostRepository = pollPostRepository;
|
||||||
|
this.categoryProposalPostRepository = categoryProposalPostRepository;
|
||||||
this.pollVoteRepository = pollVoteRepository;
|
this.pollVoteRepository = pollVoteRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.subscriptionService = subscriptionService;
|
this.subscriptionService = subscriptionService;
|
||||||
@@ -135,6 +144,7 @@ public class PostService {
|
|||||||
|
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||||
|
this.categoryService = categoryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -160,6 +170,24 @@ public class PostService {
|
|||||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||||
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
||||||
}
|
}
|
||||||
|
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
|
||||||
|
now,
|
||||||
|
CategoryProposalStatus.PENDING
|
||||||
|
)) {
|
||||||
|
if (cp.getEndTime() != null) {
|
||||||
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
|
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
||||||
|
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
);
|
||||||
|
scheduledFinalizations.put(cp.getId(), future);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
|
||||||
|
now,
|
||||||
|
CategoryProposalStatus.PENDING
|
||||||
|
)) {
|
||||||
|
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public PublishMode getPublishMode() {
|
public PublishMode getPublishMode() {
|
||||||
@@ -225,6 +253,7 @@ public class PostService {
|
|||||||
String content,
|
String content,
|
||||||
List<Long> tagIds,
|
List<Long> tagIds,
|
||||||
PostType type,
|
PostType type,
|
||||||
|
PostVisibleScopeType postVisibleScopeType,
|
||||||
String prizeDescription,
|
String prizeDescription,
|
||||||
String prizeIcon,
|
String prizeIcon,
|
||||||
Integer prizeCount,
|
Integer prizeCount,
|
||||||
@@ -232,10 +261,12 @@ public class PostService {
|
|||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
java.util.List<String> options,
|
java.util.List<String> options,
|
||||||
Boolean multiple
|
Boolean multiple,
|
||||||
|
String proposedName,
|
||||||
|
String proposalDescription
|
||||||
) {
|
) {
|
||||||
// 限制访问次数
|
// 限制访问次数
|
||||||
boolean limitResult = postRateLimit(username);
|
boolean limitResult = isPostLimitReached(username);
|
||||||
if (!limitResult) {
|
if (!limitResult) {
|
||||||
throw new RateLimitException("Too many posts");
|
throw new RateLimitException("Too many posts");
|
||||||
}
|
}
|
||||||
@@ -278,6 +309,25 @@ public class PostService {
|
|||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
pp.setMultiple(multiple != null && multiple);
|
pp.setMultiple(multiple != null && multiple);
|
||||||
post = pp;
|
post = pp;
|
||||||
|
} else if (actualType == PostType.PROPOSAL) {
|
||||||
|
CategoryProposalPost cp = new CategoryProposalPost();
|
||||||
|
if (proposedName == null || proposedName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Proposed name required");
|
||||||
|
}
|
||||||
|
String normalizedName = proposedName.trim();
|
||||||
|
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
|
||||||
|
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
|
||||||
|
}
|
||||||
|
cp.setProposedName(normalizedName);
|
||||||
|
cp.setDescription(proposalDescription);
|
||||||
|
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
|
||||||
|
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
cp.setStartAt(now);
|
||||||
|
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
|
||||||
|
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
|
||||||
|
cp.setMultiple(false);
|
||||||
|
post = cp;
|
||||||
} else {
|
} else {
|
||||||
post = new Post();
|
post = new Post();
|
||||||
}
|
}
|
||||||
@@ -288,8 +338,18 @@ public class PostService {
|
|||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new HashSet<>(tags));
|
post.setTags(new HashSet<>(tags));
|
||||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
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) {
|
if (post instanceof LotteryPost) {
|
||||||
post = lotteryPostRepository.save((LotteryPost) post);
|
post = lotteryPostRepository.save((LotteryPost) post);
|
||||||
|
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
||||||
|
post = categoryProposalPostRepository.save(categoryProposalPost);
|
||||||
} else if (post instanceof PollPost) {
|
} else if (post instanceof PollPost) {
|
||||||
post = pollPostRepository.save((PollPost) post);
|
post = pollPostRepository.save((PollPost) post);
|
||||||
} else {
|
} else {
|
||||||
@@ -344,6 +404,12 @@ public class PostService {
|
|||||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
);
|
);
|
||||||
scheduledFinalizations.put(lp.getId(), future);
|
scheduledFinalizations.put(lp.getId(), future);
|
||||||
|
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
|
||||||
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
|
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
||||||
|
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
|
);
|
||||||
|
scheduledFinalizations.put(cp.getId(), future);
|
||||||
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||||
@@ -354,24 +420,110 @@ public class PostService {
|
|||||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(post);
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
}
|
}
|
||||||
|
markPostLimit(author.getUsername());
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
|
@Transactional
|
||||||
|
public void finalizeProposal(Long postId) {
|
||||||
|
scheduledFinalizations.remove(postId);
|
||||||
|
categoryProposalPostRepository
|
||||||
|
.findById(postId)
|
||||||
|
.ifPresent(cp -> {
|
||||||
|
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
|
||||||
|
int approveVotes = 0;
|
||||||
|
if (cp.getVotes() != null) {
|
||||||
|
approveVotes = cp.getVotes().getOrDefault(0, 0);
|
||||||
|
}
|
||||||
|
boolean quorumMet = totalParticipants >= cp.getQuorum();
|
||||||
|
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
|
||||||
|
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
|
||||||
|
boolean approved = false;
|
||||||
|
String rejectReason = null;
|
||||||
|
if (quorumMet && thresholdMet) {
|
||||||
|
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
|
||||||
|
approved = true;
|
||||||
|
} else {
|
||||||
|
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
|
||||||
|
String reason;
|
||||||
|
if (!quorumMet && !thresholdMet) {
|
||||||
|
reason = "未达到法定人数且赞成率不足";
|
||||||
|
} else if (!quorumMet) {
|
||||||
|
reason = "未达到法定人数";
|
||||||
|
} else {
|
||||||
|
reason = "赞成率不足";
|
||||||
|
}
|
||||||
|
cp.setRejectReason(reason);
|
||||||
|
rejectReason = reason;
|
||||||
|
}
|
||||||
|
cp.setResultSnapshot(
|
||||||
|
"approveVotes=" +
|
||||||
|
approveVotes +
|
||||||
|
", totalParticipants=" +
|
||||||
|
totalParticipants +
|
||||||
|
", approvePercent=" +
|
||||||
|
approvePercent
|
||||||
|
);
|
||||||
|
categoryProposalPostRepository.save(cp);
|
||||||
|
if (approved) {
|
||||||
|
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
|
||||||
|
}
|
||||||
|
if (cp.getAuthor() != null) {
|
||||||
|
notificationService.createNotification(
|
||||||
|
cp.getAuthor(),
|
||||||
|
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
|
||||||
|
cp,
|
||||||
|
null,
|
||||||
|
approved,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
approved ? null : rejectReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (User participant : cp.getParticipants()) {
|
||||||
|
if (
|
||||||
|
cp.getAuthor() != null &&
|
||||||
|
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
notificationService.createNotification(
|
||||||
|
participant,
|
||||||
|
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
||||||
|
cp,
|
||||||
|
null,
|
||||||
|
approved,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
approved ? null : rejectReason
|
||||||
|
);
|
||||||
|
}
|
||||||
|
postChangeLogService.recordVoteResult(cp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 限制发帖频率
|
* 检查用户是否达到发帖限制
|
||||||
* @param username
|
* @param username
|
||||||
* @return
|
* @return true - 允许发帖,false - 已达限制
|
||||||
*/
|
*/
|
||||||
private boolean postRateLimit(String username) {
|
private boolean isPostLimitReached(String username) {
|
||||||
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
String result = (String) redisTemplate.opsForValue().get(key);
|
String result = (String) redisTemplate.opsForValue().get(key);
|
||||||
//最近没有创建过文章
|
return StringUtils.isEmpty(result);
|
||||||
if (StringUtils.isEmpty(result)) {
|
}
|
||||||
// 限制频率为5分钟
|
|
||||||
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
/**
|
||||||
return true;
|
* 标记用户发帖,触发limit计时
|
||||||
}
|
* @param username
|
||||||
return false;
|
*/
|
||||||
|
private void markPostLimit(String username) {
|
||||||
|
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
|
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
@@ -450,6 +602,9 @@ public class PostService {
|
|||||||
pollPostRepository
|
pollPostRepository
|
||||||
.findById(postId)
|
.findById(postId)
|
||||||
.ifPresent(pp -> {
|
.ifPresent(pp -> {
|
||||||
|
if (pp instanceof CategoryProposalPost) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pp.isResultAnnounced()) {
|
if (pp.isResultAnnounced()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -571,7 +726,7 @@ public class PostService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||||
if (viewer == null) {
|
if (viewer == null) {
|
||||||
throw new com.openisle.exception.NotFoundException("Post not found");
|
throw new com.openisle.exception.NotFoundException("User not found");
|
||||||
}
|
}
|
||||||
User viewerUser = userRepository
|
User viewerUser = userRepository
|
||||||
.findByUsername(viewer)
|
.findByUsername(viewer)
|
||||||
@@ -615,6 +770,18 @@ public class PostService {
|
|||||||
return listPostsByCategories(null, null, null);
|
return listPostsByCategories(null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Post> listRecentPosts(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
throw new IllegalArgumentException("Minutes must be positive");
|
||||||
|
}
|
||||||
|
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
|
||||||
|
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
||||||
|
PostStatus.PUBLISHED,
|
||||||
|
since
|
||||||
|
);
|
||||||
|
return sortByPinnedAndCreated(posts);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||||
return listPostsByViews(null, null, page, pageSize);
|
return listPostsByViews(null, null, page, pageSize);
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1169,8 @@ public class PostService {
|
|||||||
Long categoryId,
|
Long categoryId,
|
||||||
String title,
|
String title,
|
||||||
String content,
|
String content,
|
||||||
java.util.List<Long> tagIds
|
List<Long> tagIds,
|
||||||
|
PostVisibleScopeType postVisibleScopeType
|
||||||
) {
|
) {
|
||||||
if (tagIds == null || tagIds.isEmpty()) {
|
if (tagIds == null || tagIds.isEmpty()) {
|
||||||
throw new IllegalArgumentException("At least one tag required");
|
throw new IllegalArgumentException("At least one tag required");
|
||||||
@@ -1034,6 +1202,8 @@ public class PostService {
|
|||||||
post.setContent(content);
|
post.setContent(content);
|
||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new java.util.HashSet<>(tags));
|
post.setTags(new java.util.HashSet<>(tags));
|
||||||
|
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
||||||
|
post.setVisibleScope(postVisibleScopeType);
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
imageUploader.adjustReferences(oldContent, content);
|
imageUploader.adjustReferences(oldContent, content);
|
||||||
notificationService.notifyMentions(content, user, updated, null);
|
notificationService.notifyMentions(content, user, updated, null);
|
||||||
@@ -1055,6 +1225,14 @@ public class PostService {
|
|||||||
if (!oldTags.equals(newTags)) {
|
if (!oldTags.equals(newTags)) {
|
||||||
postChangeLogService.recordTagChange(updated, user, oldTags, 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) {
|
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(updated);
|
searchIndexEventPublisher.publishPostSaved(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||||
spring.data.redis.port=${REDIS_PORT:6379}
|
spring.data.redis.port=${REDIS_PORT:6379}
|
||||||
spring.data.redis.database=${REDIS_DATABASE:0}
|
spring.data.redis.database=${REDIS_DATABASE:0}
|
||||||
|
spring.data.redis.password=${REDIS_PASS: null}
|
||||||
|
|
||||||
# for jwt
|
# for jwt
|
||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Create table for category proposal posts (subclass of poll_posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS category_proposal_posts (
|
||||||
|
post_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
proposed_name VARCHAR(255) NOT NULL,
|
||||||
|
proposed_slug VARCHAR(255) NOT NULL,
|
||||||
|
description VARCHAR(255),
|
||||||
|
approve_threshold INT NOT NULL DEFAULT 60,
|
||||||
|
quorum INT NOT NULL DEFAULT 10,
|
||||||
|
start_at DATETIME(6) NULL,
|
||||||
|
result_snapshot LONGTEXT NULL,
|
||||||
|
reject_reason VARCHAR(255),
|
||||||
|
PRIMARY KEY (post_id),
|
||||||
|
CONSTRAINT fk_category_proposal_posts_parent
|
||||||
|
FOREIGN KEY (post_id) REFERENCES poll_posts (post_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status
|
||||||
|
ON category_proposal_posts (status);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug
|
||||||
|
ON category_proposal_posts (proposed_slug);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
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
|
@MockBean
|
||||||
private MedalService medalService;
|
private MedalService medalService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private CategoryService categoryService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private TagService tagService;
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private PointService pointService;
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
||||||
|
|
||||||
@@ -117,6 +126,11 @@ class PostControllerTest {
|
|||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
|
isNull(),
|
||||||
isNull()
|
isNull()
|
||||||
)
|
)
|
||||||
).thenReturn(post);
|
).thenReturn(post);
|
||||||
@@ -266,6 +280,11 @@ class PostControllerTest {
|
|||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
|
any(),
|
||||||
any()
|
any()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -52,6 +53,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -104,6 +106,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -130,6 +133,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -195,6 +199,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -221,6 +226,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -260,6 +266,11 @@ class PostServiceTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -273,6 +284,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -299,6 +311,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -367,6 +380,7 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -393,6 +407,7 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
|
proposalRepo,
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
|||||||
# Web push configuration
|
# Web push configuration
|
||||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||||
|
app.snippet-length=${SNIPPET_LENGTH:200}
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=production \
|
--build-arg NUXT_ENV=production \
|
||||||
frontend_service
|
frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||||
|
|
||||||
echo "👉 Build images (staging)..."
|
echo "👉 Build images (staging)..."
|
||||||
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=staging \
|
--build-arg NUXT_ENV=staging \
|
||||||
frontend_service
|
frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service
|
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# OpenSearch Service
|
# OpenSearch Service
|
||||||
opensearch:
|
opensearch:
|
||||||
@@ -61,6 +65,9 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
dashboards:
|
dashboards:
|
||||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
image: opensearchproject/opensearch-dashboards:3.0.0
|
||||||
@@ -75,6 +82,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management
|
image: rabbitmq:3.13-management
|
||||||
@@ -98,6 +109,10 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
@@ -111,6 +126,10 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||||
springboot:
|
springboot:
|
||||||
@@ -142,8 +161,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
opensearch:
|
# opensearch:
|
||||||
condition: service_healthy
|
# condition: service_healthy
|
||||||
command: >
|
command: >
|
||||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||||
@@ -155,6 +174,35 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- prod
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/mcp.Dockerfile
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-../.env}
|
||||||
|
environment:
|
||||||
|
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
||||||
|
OPENISLE_MCP_HOST: 0.0.0.0
|
||||||
|
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
||||||
|
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
||||||
|
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
||||||
|
ports:
|
||||||
|
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
||||||
|
depends_on:
|
||||||
|
springboot:
|
||||||
|
condition: service_started
|
||||||
|
networks:
|
||||||
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
|
|
||||||
websocket-service:
|
websocket-service:
|
||||||
image: maven:3.9-eclipse-temurin-17
|
image: maven:3.9-eclipse-temurin-17
|
||||||
@@ -186,6 +234,10 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
- dev_local_backend
|
||||||
|
- prod
|
||||||
|
|
||||||
frontend_dev:
|
frontend_dev:
|
||||||
image: node:20
|
image: node:20
|
||||||
@@ -208,6 +260,28 @@ services:
|
|||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
|
frontend_dev_local_backend:
|
||||||
|
image: node:20
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
|
||||||
|
working_dir: /app
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-../.env}
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
volumes:
|
||||||
|
- ../frontend_nuxt:/app
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
websocket-service:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- openisle-network
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
frontend_service:
|
frontend_service:
|
||||||
build:
|
build:
|
||||||
@@ -226,13 +300,13 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["staging", "prod"]
|
profiles:
|
||||||
|
- prod
|
||||||
|
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||||
loopback_8080:
|
loopback_8080:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
|
||||||
command:
|
command:
|
||||||
- -d
|
- -d
|
||||||
- -d
|
- -d
|
||||||
@@ -243,13 +317,37 @@ services:
|
|||||||
springboot:
|
springboot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
|
||||||
|
loopback_8080_host:
|
||||||
|
image: alpine/socat
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
|
||||||
|
command:
|
||||||
|
- -d
|
||||||
|
- -d
|
||||||
|
- -ly
|
||||||
|
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
|
||||||
|
- TCP4:host.docker.internal:8080
|
||||||
|
network_mode: "service:frontend_dev_local_backend"
|
||||||
|
depends_on:
|
||||||
|
frontend_dev_local_backend:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
loopback_8082:
|
loopback_8082:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
@@ -265,13 +363,37 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
profiles: ["dev"]
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
loopback_8082_host:
|
||||||
|
image: alpine/socat
|
||||||
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
|
||||||
|
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082(WS 纯 TCP 可直接过)
|
||||||
|
command:
|
||||||
|
- -d
|
||||||
|
- -d
|
||||||
|
- -ly
|
||||||
|
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
|
||||||
|
- TCP4:websocket-service:8082
|
||||||
|
depends_on:
|
||||||
|
websocket-service:
|
||||||
|
condition: service_healthy
|
||||||
|
network_mode: "service:frontend_dev_local_backend"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 10s
|
||||||
|
profiles:
|
||||||
|
- dev_local_backend
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
openisle-network:
|
openisle-network:
|
||||||
|
|||||||
21
docker/mcp.Dockerfile
Normal file
21
docker/mcp.Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim AS base
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY mcp/pyproject.toml mcp/README.md ./
|
||||||
|
COPY mcp/src ./src
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& pip install --no-cache-dir .
|
||||||
|
|
||||||
|
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
||||||
|
OPENISLE_MCP_PORT=8085 \
|
||||||
|
OPENISLE_MCP_TRANSPORT=streamable-http
|
||||||
|
|
||||||
|
EXPOSE 8085
|
||||||
|
|
||||||
|
CMD ["openisle-mcp"]
|
||||||
|
|
||||||
@@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
|
|||||||
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
||||||
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
|
import { checkToken } from '~/utils/auth'
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const menuVisible = ref(!isMobile.value)
|
const menuVisible = ref(!isMobile.value)
|
||||||
|
|
||||||
|
await checkToken()
|
||||||
|
|
||||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
const hideMenu = computed(() => {
|
const hideMenu = computed(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
--secondary-color: rgb(255, 255, 255);
|
--secondary-color: rgb(255, 255, 255);
|
||||||
--secondary-color-hover: rgba(10, 111, 120, 0.184);
|
--secondary-color-hover: rgba(10, 111, 120, 0.079);
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
|
--secondary-color-hover: rgba(17, 182, 197, 0.238);
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--app-menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
@@ -179,7 +180,7 @@ body {
|
|||||||
|
|
||||||
.info-content-text pre .line-numbers {
|
.info-content-text pre .line-numbers {
|
||||||
counter-reset: line-number 0;
|
counter-reset: line-number 0;
|
||||||
white-space: nowrap; /* 禁止数字换行 */
|
white-space: nowrap; /* 禁止数字换行 */
|
||||||
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
||||||
/* width: 2em; */
|
/* width: 2em; */
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -205,7 +206,6 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: var(--code-highlight-background-color);
|
background-color: var(--code-highlight-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
white-space: pre; /* 禁止自动换行 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
@@ -344,7 +344,7 @@ body {
|
|||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*处理iframe视频标签*/
|
/*处理iframe视频标签*/
|
||||||
.info-content-text iframe {
|
.info-content-text iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -370,7 +370,10 @@ body {
|
|||||||
.d2h-code-line {
|
.d2h-code-line {
|
||||||
padding-left: 10px !important;
|
padding-left: 10px !important;
|
||||||
}
|
}
|
||||||
|
/* 手机端不换行 */
|
||||||
|
.info-content-text code {
|
||||||
|
white-space: pre; /* 禁止自动换行 */
|
||||||
|
}
|
||||||
/* .d2h-diff-table {
|
/* .d2h-diff-table {
|
||||||
font-size: 6px !important;
|
font-size: 6px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
src: { type: String, required: true },
|
src: { type: String, default: '' },
|
||||||
alt: { type: String, default: '' },
|
alt: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,9 +39,6 @@ const placeholder = computed(() => {
|
|||||||
function onLoad() {
|
function onLoad() {
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
function onError() {
|
|
||||||
loaded.value = true
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="groupRef"
|
||||||
|
class="base-item-group"
|
||||||
|
:class="groupClass"
|
||||||
|
:style="groupStyle"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
@focusin="onFocusIn"
|
||||||
|
@focusout="onFocusOut"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in normalizedItems"
|
||||||
|
:key="resolveKey(item, index)"
|
||||||
|
class="base-item-group-item"
|
||||||
|
:style="{ zIndex: getZIndex(index) }"
|
||||||
|
>
|
||||||
|
<slot name="item" :item="item" :index="index"></slot>
|
||||||
|
</div>
|
||||||
|
<slot name="after"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
itemKey: {
|
||||||
|
type: [String, Function],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
overlap: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
expandedGap: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 8,
|
||||||
|
},
|
||||||
|
direction: {
|
||||||
|
type: String,
|
||||||
|
default: 'horizontal',
|
||||||
|
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||||
|
},
|
||||||
|
reverse: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
animationDuration: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupRef = ref(null)
|
||||||
|
const state = reactive({
|
||||||
|
hovering: false,
|
||||||
|
focused: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedItems = computed(() => props.items || [])
|
||||||
|
|
||||||
|
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
|
||||||
|
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
|
||||||
|
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
|
||||||
|
|
||||||
|
const groupClass = computed(() => [
|
||||||
|
`base-item-group--${props.direction}`,
|
||||||
|
{
|
||||||
|
'is-expanded': isExpanded.value,
|
||||||
|
'is-reversed': props.reverse,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const groupStyle = computed(() => ({
|
||||||
|
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
|
||||||
|
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
|
||||||
|
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isExpanded = computed(() => state.hovering || state.focused)
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
state.hovering = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
state.hovering = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusIn() {
|
||||||
|
state.focused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFocusOut(event) {
|
||||||
|
const nextTarget = event.relatedTarget
|
||||||
|
if (!groupRef.value) {
|
||||||
|
state.focused = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
|
||||||
|
state.focused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveKey(item, index) {
|
||||||
|
if (typeof props.itemKey === 'function') {
|
||||||
|
return props.itemKey(item, index)
|
||||||
|
}
|
||||||
|
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
|
||||||
|
return item[props.itemKey]
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZIndex(index) {
|
||||||
|
if (props.reverse) {
|
||||||
|
return index + 1
|
||||||
|
}
|
||||||
|
return normalizedItems.value.length - index
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-item-group {
|
||||||
|
--base-item-group-overlap: 12px;
|
||||||
|
--base-item-group-expanded-gap: 8px;
|
||||||
|
--base-item-group-transition-duration: 200ms;
|
||||||
|
display: inline-flex;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group:focus-within {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--horizontal {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--horizontal.is-reversed {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--vertical {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--vertical.is-reversed {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group-item {
|
||||||
|
transition:
|
||||||
|
margin var(--base-item-group-transition-duration) ease,
|
||||||
|
transform var(--base-item-group-transition-duration) ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||||
|
margin-left: calc(var(--base-item-group-overlap) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
|
||||||
|
margin-left: var(--base-item-group-expanded-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||||
|
margin-top: calc(var(--base-item-group-overlap) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
|
||||||
|
margin-top: var(--base-item-group-expanded-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group.is-expanded .base-item-group-item {
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLink
|
<div
|
||||||
:to="resolvedLink"
|
|
||||||
class="base-user-avatar"
|
class="base-user-avatar"
|
||||||
:class="wrapperClass"
|
:class="wrapperClass"
|
||||||
:style="wrapperStyle"
|
:style="wrapperStyle"
|
||||||
v-bind="wrapperAttrs"
|
v-bind="wrapperAttrs"
|
||||||
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
|
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
|
||||||
</NuxtLink>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
import BaseImage from './BaseImage.vue'
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
const DEFAULT_AVATAR = '/default-avatar.svg'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
userId: {
|
userId: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@@ -50,15 +48,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
const currentSrc = ref(props.src || DEFAULT_AVATAR)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.src,
|
|
||||||
(value) => {
|
|
||||||
currentSrc.value = value || DEFAULT_AVATAR
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolvedLink = computed(() => {
|
const resolvedLink = computed(() => {
|
||||||
if (props.to) return props.to
|
if (props.to) return props.to
|
||||||
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
||||||
@@ -70,10 +59,16 @@ const resolvedLink = computed(() => {
|
|||||||
const altText = computed(() => props.alt || '用户头像')
|
const altText = computed(() => props.alt || '用户头像')
|
||||||
|
|
||||||
const sizeStyle = computed(() => {
|
const sizeStyle = computed(() => {
|
||||||
if (!props.width && props.width !== 0) return null
|
var style = {}
|
||||||
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
|
|
||||||
if (!value) return null
|
if (props.width > 0) {
|
||||||
return { width: value, height: value }
|
style.width = `${props.width}px`
|
||||||
|
}
|
||||||
|
if (props.height > 0) {
|
||||||
|
style.height = `${props.height}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
@@ -88,10 +83,9 @@ const wrapperAttrs = computed(() => {
|
|||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
|
||||||
function onError() {
|
const handleClick = () => {
|
||||||
if (currentSrc.value !== DEFAULT_AVATAR) {
|
if (props.disableLink) return
|
||||||
currentSrc.value = DEFAULT_AVATAR
|
navigateTo(resolvedLink.value)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,7 +103,7 @@ function onError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-user-avatar:hover {
|
.base-user-avatar:hover {
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,14 +53,29 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
<ReactionsGroup
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
ref="commentReactionsGroupRef"
|
||||||
|
v-model="comment.reactions"
|
||||||
|
content-type="comment"
|
||||||
|
:content-id="comment.id"
|
||||||
|
/>
|
||||||
|
<div class="comment-reaction-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: commentLikedByMe }"
|
||||||
|
@click="toggleCommentLike"
|
||||||
|
>
|
||||||
|
<like v-if="!commentLikedByMe" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-action comment-reaction" @click="toggleEditor">
|
||||||
<comment-icon />
|
<comment-icon />
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
<div class="reaction-action copy-link" @click="copyCommentLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
|
|||||||
const lightboxIndex = ref(0)
|
const lightboxIndex = ref(0)
|
||||||
const lightboxImgs = ref([])
|
const lightboxImgs = ref([])
|
||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const commentReactionsGroupRef = ref(null)
|
||||||
|
const commentLikeCount = computed(
|
||||||
|
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
|
||||||
|
)
|
||||||
|
const commentLikedByMe = computed(() =>
|
||||||
|
(props.comment.reactions || []).some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const toggleCommentLike = () => {
|
||||||
|
commentReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
const isCommentFromPostAuthor = computed(() => {
|
const isCommentFromPostAuthor = computed(() => {
|
||||||
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.comment-reaction-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 18px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.reply-toggle {
|
.reply-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-reaction:hover {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
animation: highlight 2s;
|
animation: highlight 2s;
|
||||||
}
|
}
|
||||||
@@ -424,6 +488,16 @@ const handleContentClick = (e) => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-footer-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 0px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
.medal-name {
|
.medal-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
|
|||||||
319
frontend_nuxt/components/DonateGroup.vue
Normal file
319
frontend_nuxt/components/DonateGroup.vue
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<template>
|
||||||
|
<div class="donate-container">
|
||||||
|
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
||||||
|
<div class="donate-viewer" @click="openPanel">
|
||||||
|
<div
|
||||||
|
class="donate-viewer-item-container"
|
||||||
|
@mouseenter="cancelHide"
|
||||||
|
@mouseleave="scheduleHide"
|
||||||
|
>
|
||||||
|
<BaseItemGroup
|
||||||
|
:items="donationList"
|
||||||
|
:overlap="10"
|
||||||
|
:expanded-gap="2"
|
||||||
|
:direction="vertical"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<BaseUserAvatar
|
||||||
|
:user-id="item.userId"
|
||||||
|
:src="item.avatar"
|
||||||
|
:alt="item.username"
|
||||||
|
:width="20"
|
||||||
|
:disable-link="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</BaseItemGroup>
|
||||||
|
<div class="donate-counts-text">{{ totalAmount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
<ToolTip content="赞赏作者" placement="bottom" v-else>
|
||||||
|
<div class="donate-viewer-item placeholder" @click="openPanel">
|
||||||
|
<financing class="donate-viewer-item-placeholder-icon" />
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
<div
|
||||||
|
v-if="panelVisible"
|
||||||
|
class="donate-panel"
|
||||||
|
ref="donatePanelRef"
|
||||||
|
:style="panelInlineStyle"
|
||||||
|
@mouseenter="cancelHide"
|
||||||
|
@mouseleave="scheduleHide"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="option in donateOptions"
|
||||||
|
:key="option"
|
||||||
|
class="donate-option"
|
||||||
|
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
||||||
|
@click="handleDonate(option)"
|
||||||
|
>
|
||||||
|
<financing class="donate-option-icon" />
|
||||||
|
<div class="donate-counts-text">{{ option }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Finance } from '@icon-park/vue-next'
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { toast } from '~/main'
|
||||||
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
|
||||||
|
const financing = Finance
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
postId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
authorId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isAuthor: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
|
const panelVisible = ref(false)
|
||||||
|
const donatePanelRef = ref(null)
|
||||||
|
const panelInlineStyle = ref({})
|
||||||
|
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
||||||
|
const donating = ref(false)
|
||||||
|
let hideTimer = null
|
||||||
|
|
||||||
|
const donateOptions = [10, 30, 100]
|
||||||
|
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
||||||
|
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
||||||
|
const isAuthorUser = computed(() => {
|
||||||
|
if (props.isAuthor) return true
|
||||||
|
if (!authState.userId || !props.authorId) return false
|
||||||
|
return Number(authState.userId) === Number(props.authorId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const openPanel = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
panelVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
hideTimer = setTimeout(() => {
|
||||||
|
panelVisible.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
const cancelHide = () => {
|
||||||
|
clearTimeout(hideTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePanelInlineStyle = () => {
|
||||||
|
if (!panelVisible.value) return
|
||||||
|
const panelEl = donatePanelRef.value
|
||||||
|
if (!panelEl) return
|
||||||
|
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
|
||||||
|
if (!parentEl) return
|
||||||
|
const parentWidth = parentEl.clientWidth - 20
|
||||||
|
panelInlineStyle.value = {
|
||||||
|
width: 'max-content',
|
||||||
|
maxWidth: `${parentWidth}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(panelVisible, async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
await nextTick()
|
||||||
|
updatePanelInlineStyle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeSummary = (data) => ({
|
||||||
|
totalAmount: data?.totalAmount ?? 0,
|
||||||
|
donations: Array.isArray(data?.donations) ? data.donations : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadDonations = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
donationSummary.value = normalizeSummary(data)
|
||||||
|
} catch (e) {
|
||||||
|
// ignore network errors for donation summary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDonate = async (amount) => {
|
||||||
|
if (!amount || donating.value) return
|
||||||
|
if (!authState.loggedIn) {
|
||||||
|
toast.error('请先登录后再打赏')
|
||||||
|
panelVisible.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isAuthorUser.value) {
|
||||||
|
toast.warning('不能给自己打赏')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
donating.value = true
|
||||||
|
const token = getToken()
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: token ? `Bearer ${token}` : '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ amount }),
|
||||||
|
})
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
toast.error('请先登录后再打赏')
|
||||||
|
} else {
|
||||||
|
toast.error(data?.error || '打赏失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
donationSummary.value = normalizeSummary(data)
|
||||||
|
toast.success('打赏成功,感谢你的支持!')
|
||||||
|
panelVisible.value = false
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('打赏失败,请稍后再试')
|
||||||
|
} finally {
|
||||||
|
donating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', updatePanelInlineStyle)
|
||||||
|
await loadDonations()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.postId,
|
||||||
|
async () => {
|
||||||
|
donationSummary.value = { totalAmount: 0, donations: [] }
|
||||||
|
await loadDonations()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.donate-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer-item-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer {
|
||||||
|
border-radius: 13px;
|
||||||
|
padding: 3px;
|
||||||
|
padding-right: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer:hover {
|
||||||
|
background-color: var(--secondary-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-counts-text {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 35px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 5px;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer-item.placeholder {
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-direction: row;
|
||||||
|
padding: 2px 10px;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer-item {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-viewer-item-placeholder-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-option {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-option:hover {
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-option.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-option.disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.donate-option-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.donate-viewer-item.placeholder {
|
||||||
|
padding: 4px 8px;
|
||||||
|
gap: 3px;
|
||||||
|
border: 1px solid var(--normal-border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-right: 3px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-color);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -168,9 +168,19 @@ export default {
|
|||||||
const mobileMenuRef = ref(null)
|
const mobileMenuRef = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
if (!open.value) {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
open.value = !open.value
|
if (open.value) {
|
||||||
if (!open.value) emit('close')
|
open.value = false
|
||||||
|
emit('close')
|
||||||
|
} else {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -275,7 +285,7 @@ export default {
|
|||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
expose({ toggle, close, reload, scrollToBottom })
|
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
@@ -308,7 +318,6 @@ export default {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
margin-bottom: 4px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -331,6 +340,7 @@ export default {
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
|
margin-top: 4px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div class="header-content-right">
|
<div class="header-content-right">
|
||||||
|
<SearchDropdown
|
||||||
|
ref="searchDropdown"
|
||||||
|
v-if="!isMobile || showSearch"
|
||||||
|
@close="closeSearch"
|
||||||
|
/>
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||||
<div class="header-icon-item" @click="search">
|
<div class="header-icon-item" @click="search">
|
||||||
@@ -78,7 +83,9 @@
|
|||||||
<div class="header-icon-item" @click="goToMessages">
|
<div class="header-icon-item" @click="goToMessages">
|
||||||
<message-emoji class="header-icon" />
|
<message-emoji class="header-icon" />
|
||||||
<span class="header-label">消息</span>
|
<span class="header-label">消息</span>
|
||||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
|
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||||
|
unreadMessageCount
|
||||||
|
}}</span>
|
||||||
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
@@ -89,10 +96,9 @@
|
|||||||
<BaseUserAvatar
|
<BaseUserAvatar
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
:user-id="authState.userId"
|
:user-id="authState.userId"
|
||||||
:src="avatar"
|
:src="authState.avatar"
|
||||||
alt="avatar"
|
|
||||||
:width="32"
|
|
||||||
:disable-link="true"
|
:disable-link="true"
|
||||||
|
:width="32"
|
||||||
/>
|
/>
|
||||||
<down />
|
<down />
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +111,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -117,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
|||||||
import ToolTip from '~/components/ToolTip.vue'
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
import { authState, clearToken } from '~/utils/auth'
|
||||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -139,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn)
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||||
const avatar = ref('')
|
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = ref(null)
|
const menuBtn = ref(null)
|
||||||
const isCopying = ref(false)
|
const isCopying = ref(false)
|
||||||
|
|
||||||
const onlineCount = ref(0)
|
const onlineCount = ref(0)
|
||||||
|
|
||||||
// 心跳检测
|
// 心跳检测
|
||||||
@@ -208,7 +211,7 @@ const copyInviteLink = async () => {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -252,17 +255,7 @@ const copyRssLink = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToProfile = async () => {
|
const goToProfile = async () => {
|
||||||
if (!authState.loggedIn) {
|
let id = authState.username || authState.id
|
||||||
navigateTo('/login', { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let id = authState.username || authState.userId
|
|
||||||
if (!id) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user) {
|
|
||||||
id = user.username || user.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (id) {
|
if (id) {
|
||||||
navigateTo(`/users/${id}`, { replace: true })
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -306,14 +299,6 @@ const iconClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const updateAvatar = async () => {
|
|
||||||
if (authState.loggedIn) {
|
|
||||||
const user = await loadCurrentUser()
|
|
||||||
if (user && user.avatar) {
|
|
||||||
avatar.value = user.avatar
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const updateUnread = async () => {
|
const updateUnread = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
fetchUnreadCount()
|
fetchUnreadCount()
|
||||||
@@ -323,17 +308,8 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
|
||||||
watch(
|
|
||||||
() => authState.loggedIn,
|
|
||||||
async (isLoggedIn) => {
|
|
||||||
await updateAvatar()
|
|
||||||
await updateUnread()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 新增的在线人数逻辑
|
// 新增的在线人数逻辑
|
||||||
sendPing()
|
sendPing()
|
||||||
fetchCount()
|
fetchCount()
|
||||||
@@ -482,7 +458,6 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.invite_text:hover {
|
.invite_text:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -543,7 +518,10 @@ onMounted(async () => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
|
transition:
|
||||||
|
color 0.25s ease,
|
||||||
|
transform 0.15s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon-item:hover {
|
.header-icon-item:hover {
|
||||||
@@ -565,6 +543,7 @@ onMounted(async () => {
|
|||||||
.header-label {
|
.header-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 在线人数的数字文字样式(无背景) */
|
/* 在线人数的数字文字样式(无背景) */
|
||||||
@@ -572,15 +551,14 @@ onMounted(async () => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -6px;
|
right: -6px;
|
||||||
color: var(--primary-color); /* 🔹 使用主题主色 */
|
color: var(--primary-color); /* 🔹 使用主题主色 */
|
||||||
background: none; /* 🔹 去掉背景 */
|
background: none; /* 🔹 去掉背景 */
|
||||||
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
||||||
font-weight: 600; /* 加一点权重让数字更醒目 */
|
font-weight: 600; /* 加一点权重让数字更醒目 */
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0; /* 去掉内边距 */
|
padding: 0; /* 去掉内边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes rss-glow {
|
@keyframes rss-glow {
|
||||||
0% {
|
0% {
|
||||||
text-shadow: 0 0 0px var(--primary-color);
|
text-shadow: 0 0 0px var(--primary-color);
|
||||||
|
|||||||
@@ -3,15 +3,30 @@
|
|||||||
<div class="login-overlay-blur"></div>
|
<div class="login-overlay-blur"></div>
|
||||||
<div class="login-overlay-content">
|
<div class="login-overlay-content">
|
||||||
<user-icon class="login-overlay-icon" />
|
<user-icon class="login-overlay-icon" />
|
||||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
<div class="login-overlay-text">{{ props.text }}</div>
|
||||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: '请先登录,点击跳转到登录页面',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: '登录',
|
||||||
|
},
|
||||||
|
buttonLink: {
|
||||||
|
type: String,
|
||||||
|
default: '/login',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const goLogin = () => {
|
const goLogin = () => {
|
||||||
navigateTo('/login', { replace: true })
|
navigateTo(props.buttonLink, { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-read-button:hover {
|
.mark-read-button:hover {
|
||||||
@@ -53,6 +54,7 @@ export default {
|
|||||||
|
|
||||||
.has-read-button {
|
.has-read-button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -4,11 +4,7 @@
|
|||||||
<span class="poll-row-title">投票选项</span>
|
<span class="poll-row-title">投票选项</span>
|
||||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||||
<i
|
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
|
||||||
v-if="data.options.length > 2"
|
|
||||||
class="fa-solid fa-xmark remove-option-icon"
|
|
||||||
@click="removeOption(idx)"
|
|
||||||
></i>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="add-option" @click="addOption">添加选项</div>
|
<div class="add-option" @click="addOption">添加选项</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,12 +36,19 @@
|
|||||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||||
<template v-else>取消精选文章</template>
|
<template v-else>取消精选文章</template>
|
||||||
</span>
|
</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 v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||||
>系统已计算投票结果</span
|
>系统已计算投票结果</span
|
||||||
>
|
>
|
||||||
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
||||||
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
||||||
>
|
>
|
||||||
|
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
|
||||||
|
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="change-log-time">{{ log.time }}</div>
|
<div class="change-log-time">{{ log.time }}</div>
|
||||||
<div
|
<div
|
||||||
@@ -66,6 +73,17 @@ const props = defineProps({
|
|||||||
title: String,
|
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(() => {
|
const diffHtml = computed(() => {
|
||||||
// Track theme changes
|
// Track theme changes
|
||||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
<div class="post-poll-container" v-if="poll">
|
<div class="post-poll-container" v-if="poll">
|
||||||
<div class="poll-top-container">
|
<div class="poll-top-container">
|
||||||
<div class="poll-options-container">
|
<div class="poll-options-container">
|
||||||
|
<div class="poll-title-section">
|
||||||
|
<div class="poll-title-section-row">
|
||||||
|
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
||||||
|
<div class="poll-option-title" v-else-if="isProposal">
|
||||||
|
拟议分类:{{ poll.proposedName }}
|
||||||
|
<ToolTip
|
||||||
|
content="🗳️ 提案提交后将开放3天投票,需达到至少60%的赞成率并满10人参与方可通过。"
|
||||||
|
placement="bottom"
|
||||||
|
v-if="isProposal"
|
||||||
|
>
|
||||||
|
<info-icon class="info-icon" />
|
||||||
|
</ToolTip>
|
||||||
|
</div>
|
||||||
|
<div class="poll-option-title" v-else>单选</div>
|
||||||
|
<div class="poll-left-time">
|
||||||
|
<stopwatch class="poll-left-time-icon" />
|
||||||
|
<div class="poll-left-time-title">离结束</div>
|
||||||
|
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="poll-title-section-row">
|
||||||
|
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div v-if="showPollResult || pollEnded || hasVoted">
|
<div v-if="showPollResult || pollEnded || hasVoted">
|
||||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||||
<div class="poll-option-info-container">
|
<div class="poll-option-info-container">
|
||||||
@@ -29,16 +53,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="poll-title-section">
|
|
||||||
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
|
||||||
<div class="poll-option-title" v-else>单选</div>
|
|
||||||
|
|
||||||
<div class="poll-left-time">
|
|
||||||
<stopwatch class="poll-left-time-icon" />
|
|
||||||
<div class="poll-left-time-title">离结束</div>
|
|
||||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template v-if="poll.multiple">
|
<template v-if="poll.multiple">
|
||||||
<div
|
<div
|
||||||
v-for="(opt, idx) in poll.options"
|
v-for="(opt, idx) in poll.options"
|
||||||
@@ -103,11 +117,6 @@
|
|||||||
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||||
<div v-else class="poll-option-hint">
|
<div v-else class="poll-option-hint">
|
||||||
<div>您已投票,等待结束查看结果</div>
|
<div>您已投票,等待结束查看结果</div>
|
||||||
<div class="poll-left-time">
|
|
||||||
<stopwatch class="poll-left-time-icon" />
|
|
||||||
<div class="poll-left-time-title">离结束</div>
|
|
||||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,6 +139,9 @@ const emit = defineEmits(['refresh'])
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const showPollResult = ref(false)
|
const showPollResult = ref(false)
|
||||||
|
|
||||||
|
const isProposal = computed(() =>
|
||||||
|
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
|
||||||
|
)
|
||||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||||
const pollVotes = computed(() => props.poll?.votes || {})
|
const pollVotes = computed(() => props.poll?.votes || {})
|
||||||
@@ -233,6 +245,34 @@ const submitMultiPoll = async () => {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proposal-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-status {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proposal-description {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-option-button {
|
.poll-option-button {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -385,12 +425,20 @@ const submitMultiPoll = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-title-section {
|
.poll-title-section {
|
||||||
display: flex;
|
|
||||||
gap: 30px;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-title-section-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-option-title {
|
.poll-option-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default {
|
|||||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||||
|
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
@@ -18,9 +18,11 @@
|
|||||||
<div>{{ counts[r.type] }}</div>
|
<div>{{ counts[r.type] }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
<ToolTip content="发表心情" placement="bottom">
|
||||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||||
</div>
|
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="displayedReactions.length">
|
<template v-else-if="displayedReactions.length">
|
||||||
<div
|
<div
|
||||||
@@ -35,21 +37,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-container">
|
|
||||||
<div
|
|
||||||
v-if="props.contentType !== 'message'"
|
|
||||||
class="make-reaction-item like-reaction"
|
|
||||||
@click="toggleReaction('LIKE')"
|
|
||||||
>
|
|
||||||
<like v-if="!userReacted('LIKE')" />
|
|
||||||
<like v-else theme="filled" />
|
|
||||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
|
||||||
</div>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="panelVisible"
|
v-if="panelVisible"
|
||||||
class="reactions-panel"
|
class="reactions-panel"
|
||||||
|
ref="reactionsPanelRef"
|
||||||
|
:style="panelInlineStyle"
|
||||||
@mouseenter="cancelHide"
|
@mouseenter="cancelHide"
|
||||||
@mouseleave="scheduleHide"
|
@mouseleave="scheduleHide"
|
||||||
>
|
>
|
||||||
@@ -69,7 +61,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
@@ -102,8 +94,6 @@ const counts = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
|
||||||
|
|
||||||
const userReacted = (type) =>
|
const userReacted = (type) =>
|
||||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
@@ -152,9 +142,11 @@ const displayedReactions = computed(() => {
|
|||||||
.map((type) => ({ type }))
|
.map((type) => ({ type }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
|
const panelTypes = computed(() => sortedReactionTypes.value)
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
const panelVisible = ref(false)
|
||||||
|
const reactionsPanelRef = ref(null)
|
||||||
|
const panelInlineStyle = ref({})
|
||||||
let hideTimer = null
|
let hideTimer = null
|
||||||
const openPanel = () => {
|
const openPanel = () => {
|
||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
@@ -170,6 +162,33 @@ const cancelHide = () => {
|
|||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePanelInlineStyle = () => {
|
||||||
|
if (!panelVisible.value) return
|
||||||
|
const panelEl = reactionsPanelRef.value
|
||||||
|
if (!panelEl) return
|
||||||
|
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
|
||||||
|
if (!parentEl) return
|
||||||
|
const parentWidth = parentEl.clientWidth - 20
|
||||||
|
panelInlineStyle.value = {
|
||||||
|
width: 'max-content',
|
||||||
|
maxWidth: `${parentWidth}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(panelVisible, async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
await nextTick()
|
||||||
|
updatePanelInlineStyle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(panelTypes, async () => {
|
||||||
|
if (panelVisible.value) {
|
||||||
|
await nextTick()
|
||||||
|
updatePanelInlineStyle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const toggleReaction = async (type) => {
|
const toggleReaction = async (type) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -245,6 +264,15 @@ const toggleReaction = async (type) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initialize()
|
await initialize()
|
||||||
|
window.addEventListener('resize', updatePanelInlineStyle)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleReaction,
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -253,11 +281,7 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.reactions-viewer {
|
||||||
@@ -295,40 +319,15 @@ onMounted(async () => {
|
|||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.make-reaction-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-item {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.like-reaction {
|
|
||||||
color: #ff0000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-item:hover {
|
|
||||||
background-color: #ffe2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-count {
|
.reactions-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-panel {
|
.reactions-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 50px;
|
bottom: 35px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -361,7 +360,6 @@ onMounted(async () => {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
margin-bottom: 5px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
placeholder="Search"
|
placeholder="键盘点击「/」以触发搜索"
|
||||||
|
ref="searchInput"
|
||||||
@input="setSearch(keyword)"
|
@input="setSearch(keyword)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -61,8 +62,48 @@ const keyword = ref('')
|
|||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const dropdown = ref(null)
|
const dropdown = ref(null)
|
||||||
|
const searchInput = ref(null)
|
||||||
const isMobile = useIsMobile()
|
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 = () => {
|
const toggle = () => {
|
||||||
dropdown.value.toggle()
|
dropdown.value.toggle()
|
||||||
}
|
}
|
||||||
@@ -144,8 +185,7 @@ defineExpose({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-dropdown {
|
.search-dropdown {
|
||||||
margin-top: 20px;
|
width: 300px;
|
||||||
width: 500px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-mobile-trigger {
|
.search-mobile-trigger {
|
||||||
@@ -154,7 +194,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
padding: 10px;
|
padding: 2px 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -202,7 +242,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-body {
|
.result-body {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -216,4 +256,14 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
<div v-if="!isMobile" class="search-container">
|
|
||||||
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
|
||||||
<SearchDropdown />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="topic-container">
|
<div class="topic-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div
|
<div
|
||||||
@@ -72,11 +67,13 @@
|
|||||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||||
|
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
||||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
|
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
<div v-html="sanitizeDescription(article.description)"></div>
|
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="article-info-container main-item">
|
<div class="article-info-container main-item">
|
||||||
<ArticleCategory :category="article.category" />
|
<ArticleCategory :category="article.category" />
|
||||||
@@ -116,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
|
|
||||||
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||||
<InfiniteLoadMore
|
<InfiniteLoadMore
|
||||||
v-if="articles.length > 0"
|
v-if="articles.length > 0"
|
||||||
:key="ioKey"
|
:key="ioKey"
|
||||||
@@ -143,6 +140,7 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
||||||
|
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
||||||
useHead({
|
useHead({
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
meta: [
|
meta: [
|
||||||
@@ -298,6 +296,7 @@ const {
|
|||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
@@ -339,6 +338,7 @@ const fetchNextPage = async () => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
|
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
@@ -378,28 +378,6 @@ onBeforeUnmount(() => {
|
|||||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||||
|
|
||||||
// 在首页摘要加载贴吧表情包
|
|
||||||
const sanitizeDescription = (text) => {
|
|
||||||
if (!text) return ''
|
|
||||||
|
|
||||||
// 1️⃣ 先把 Markdown 转成纯文本
|
|
||||||
const plain = stripMarkdown(text)
|
|
||||||
|
|
||||||
// 2️⃣ 替换 :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 // 没有匹配到图片则保留原样
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3️⃣ 可选:截断纯文本长度(防止撑太长)
|
|
||||||
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
|
|
||||||
|
|
||||||
return truncated
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面选项同步到全局状态
|
// 页面选项同步到全局状态
|
||||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||||
selectedCategoryGlobal.value = newCategory
|
selectedCategoryGlobal.value = newCategory
|
||||||
@@ -564,14 +542,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.header-item.views {
|
.header-item.views {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-time,
|
.article-time,
|
||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-item-title {
|
.article-item-title {
|
||||||
@@ -593,6 +571,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.pinned-icon,
|
.pinned-icon,
|
||||||
.lottery-icon,
|
.lottery-icon,
|
||||||
.featured-icon,
|
.featured-icon,
|
||||||
|
.proposal-icon,
|
||||||
.poll-icon {
|
.poll-icon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
import { setToken } from '~/utils/auth'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
@@ -61,7 +61,6 @@ const submitLogin = async () => {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
await navigateTo('/', { replace: true })
|
await navigateTo('/', { replace: true })
|
||||||
|
|||||||
@@ -61,14 +61,31 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<ReactionsGroup
|
<div class="message-reaction-row">
|
||||||
:model-value="item.reactions"
|
<ReactionsGroup
|
||||||
content-type="message"
|
:ref="(el) => setMessageReactionRef(item.id, el)"
|
||||||
:content-id="item.id"
|
:model-value="item.reactions"
|
||||||
@update:modelValue="(v) => (item.reactions = v)"
|
content-type="message"
|
||||||
>
|
:content-id="item.id"
|
||||||
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
|
@update:modelValue="(v) => (item.reactions = v)"
|
||||||
</ReactionsGroup>
|
/>
|
||||||
|
<div class="message-reaction-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: isMessageLiked(item) }"
|
||||||
|
@click="toggleMessageLike(item)"
|
||||||
|
>
|
||||||
|
<like v-if="!isMessageLiked(item)" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="getMessageLikeCount(item)" class="reaction-count">{{
|
||||||
|
getMessageLikeCount(item)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div @click="setReply(item)" class="reaction-action reply-btn">
|
||||||
|
<next /> 写个回复...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
<div class="empty-container">
|
<div class="empty-container">
|
||||||
@@ -180,6 +197,32 @@ function setReply(message) {
|
|||||||
replyTo.value = message
|
replyTo.value = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageReactionRefs = new Map()
|
||||||
|
function setMessageReactionRef(id, el) {
|
||||||
|
if (el) {
|
||||||
|
messageReactionRefs.set(id, el)
|
||||||
|
} else {
|
||||||
|
messageReactionRefs.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageLikeCount(message) {
|
||||||
|
return (message.reactions || []).filter((reaction) => reaction.type === 'LIKE').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageLiked(message) {
|
||||||
|
const username = currentUser.value?.username
|
||||||
|
if (!username) return false
|
||||||
|
return (message.reactions || []).some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMessageLike(message) {
|
||||||
|
const group = messageReactionRefs.get(message.id)
|
||||||
|
group?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
|
|
||||||
/** 改造:滚动函数 —— smooth & instant */
|
/** 改造:滚动函数 —— smooth & instant */
|
||||||
function scrollToBottomSmooth() {
|
function scrollToBottomSmooth() {
|
||||||
const el = messagesListEl.value
|
const el = messagesListEl.value
|
||||||
@@ -710,6 +753,55 @@ function goBack() {
|
|||||||
background-color: var(--normal-light-background-color);
|
background-color: var(--normal-light-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-reaction-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 16px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.reply-header {
|
.reply-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -723,14 +815,8 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-btn {
|
.reply-btn {
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
color: var(--primary-color);
|
||||||
|
|
||||||
.reply-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-reply {
|
.active-reply {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
<div class="conversation-avatar">
|
<div class="conversation-avatar">
|
||||||
<BaseImage
|
<BaseImage
|
||||||
:src="ch.avatar || '/default-avatar.svg'"
|
:src="ch.avatar"
|
||||||
:alt="ch.name"
|
:alt="ch.name"
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
@error="handleAvatarError"
|
@error="handleAvatarError"
|
||||||
@@ -194,7 +194,7 @@ function formatTime(timeString) {
|
|||||||
|
|
||||||
// 头像加载失败处理
|
// 头像加载失败处理
|
||||||
function handleAvatarError(event) {
|
function handleAvatarError(event) {
|
||||||
event.target.src = '/default-avatar.svg'
|
event.target.src = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChannels() {
|
async function fetchChannels() {
|
||||||
|
|||||||
@@ -75,7 +75,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
回复了
|
回复了
|
||||||
@@ -85,7 +87,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -115,7 +119,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -162,7 +168,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
进行了表态
|
进行了表态
|
||||||
@@ -251,6 +259,38 @@
|
|||||||
已出结果
|
已出结果
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你的分类提案
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="item.approved">已通过</span>
|
||||||
|
<span v-else>
|
||||||
|
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
你参与的分类提案
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-if="item.approved">已通过</span>
|
||||||
|
<span v-else>
|
||||||
|
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
||||||
|
</span>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -267,7 +307,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -287,7 +327,9 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
<span
|
||||||
|
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
||||||
|
></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
回复了
|
回复了
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -295,7 +337,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -323,7 +365,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -342,7 +384,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
{{ stripMarkdownLength(item.comment.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -542,6 +584,27 @@
|
|||||||
被收录为精选
|
被收录为精选
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'DONATION'">
|
||||||
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/users/${item.fromUser.id}`"
|
||||||
|
>
|
||||||
|
{{ item.fromUser.username }}
|
||||||
|
</NuxtLink>
|
||||||
|
在帖子
|
||||||
|
<NuxtLink
|
||||||
|
class="notif-content-text"
|
||||||
|
@click="markRead(item.id)"
|
||||||
|
:to="`/posts/${item.post.id}`"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||||
|
</NuxtLink>
|
||||||
|
打赏了你
|
||||||
|
<template v-if="item.content"> ,获得 {{ item.content }} 积分 </template>
|
||||||
|
</NotificationContainer>
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'POST_DELETED'">
|
<template v-else-if="item.type === 'POST_DELETED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
管理员
|
管理员
|
||||||
@@ -556,7 +619,7 @@
|
|||||||
</template>
|
</template>
|
||||||
删除了您的帖子
|
删除了您的帖子
|
||||||
<span class="notif-content-text">
|
<span class="notif-content-text">
|
||||||
{{ stripMarkdownLength(item.content, 100) }}
|
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -586,7 +649,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
|||||||
import BaseTabs from '~/components/BaseTabs.vue'
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownLength } from '~/utils/markdown'
|
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
@@ -754,6 +817,10 @@ const formatType = (t) => {
|
|||||||
return '发布的投票结果已公布'
|
return '发布的投票结果已公布'
|
||||||
case 'POLL_RESULT_PARTICIPANT':
|
case 'POLL_RESULT_PARTICIPANT':
|
||||||
return '参与的投票结果已公布'
|
return '参与的投票结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
|
||||||
|
return '分类提案结果已公布'
|
||||||
|
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
|
||||||
|
return '参与的分类提案结果已公布'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
<PostTypeSelect v-model="postType" />
|
<PostTypeSelect v-model="postType" />
|
||||||
|
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||||
|
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,8 +52,10 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
|||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import LotteryForm from '~/components/LotteryForm.vue'
|
import LotteryForm from '~/components/LotteryForm.vue'
|
||||||
import PollForm from '~/components/PollForm.vue'
|
import PollForm from '~/components/PollForm.vue'
|
||||||
|
import ProposalForm from '~/components/ProposalForm.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
|
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -60,6 +64,7 @@ const content = ref('')
|
|||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const postType = ref('NORMAL')
|
const postType = ref('NORMAL')
|
||||||
|
const postVisibleScope = ref('ALL')
|
||||||
const lottery = reactive({
|
const lottery = reactive({
|
||||||
prizeIcon: '',
|
prizeIcon: '',
|
||||||
prizeIconFile: null,
|
prizeIconFile: null,
|
||||||
@@ -76,6 +81,10 @@ const poll = reactive({
|
|||||||
endTime: null,
|
endTime: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
const proposal = reactive({
|
||||||
|
proposedName: '',
|
||||||
|
proposalDescription: '',
|
||||||
|
})
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
@@ -94,6 +103,7 @@ const loadDraft = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.categoryId || ''
|
selectedCategory.value = data.categoryId || ''
|
||||||
selectedTags.value = data.tagIds || []
|
selectedTags.value = data.tagIds || []
|
||||||
|
postVisibleScope.value = data.visiblescope
|
||||||
|
|
||||||
toast.success('草稿已加载')
|
toast.success('草稿已加载')
|
||||||
}
|
}
|
||||||
@@ -109,6 +119,7 @@ const clearPost = async () => {
|
|||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
|
postVisibleScope.value = 'ALL'
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
lottery.prizeIcon = ''
|
lottery.prizeIcon = ''
|
||||||
lottery.prizeIconFile = null
|
lottery.prizeIconFile = null
|
||||||
@@ -123,6 +134,8 @@ const clearPost = async () => {
|
|||||||
poll.options = ['', '']
|
poll.options = ['', '']
|
||||||
poll.endTime = null
|
poll.endTime = null
|
||||||
poll.multiple = false
|
poll.multiple = false
|
||||||
|
proposal.proposedName = ''
|
||||||
|
proposal.proposalDescription = ''
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -160,6 +173,7 @@ const saveDraft = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds,
|
tagIds,
|
||||||
|
postVisibleScopeType:postVisibleScope.value
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -283,6 +297,12 @@ const submitPost = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (postType.value === 'PROPOSAL') {
|
||||||
|
if (!proposal.proposedName.trim()) {
|
||||||
|
toast.error('请填写拟议分类名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
@@ -303,36 +323,46 @@ const submitPost = async () => {
|
|||||||
}
|
}
|
||||||
prizeIconUrl = uploadData.data.url
|
prizeIconUrl = uploadData.data.url
|
||||||
}
|
}
|
||||||
|
const toUtcString = (value) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
categoryId: selectedCategory.value,
|
||||||
|
tagIds: selectedTags.value,
|
||||||
|
type: postType.value,
|
||||||
|
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`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
type: postType.value,
|
|
||||||
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
|
|
||||||
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
|
|
||||||
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
|
||||||
options: postType.value === 'POLL' ? poll.options : undefined,
|
|
||||||
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
|
|
||||||
startTime:
|
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
|
||||||
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
|
||||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
|
||||||
endTime:
|
|
||||||
postType.value === 'LOTTERY'
|
|
||||||
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: postType.value === 'POLL'
|
|
||||||
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (data.reward && data.reward > 0) {
|
if (data.reward && data.reward > 0) {
|
||||||
|
|||||||
@@ -184,6 +184,27 @@
|
|||||||
}}</NuxtLink>
|
}}</NuxtLink>
|
||||||
参与,获得 {{ item.amount }} 积分
|
参与,获得 {{ item.amount }} 积分
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="item.type === 'DONATE_SENT'">
|
||||||
|
你在文章
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
中打赏了
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
,消耗 {{ -item.amount }} 积分
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'DONATE_RECEIVED'">
|
||||||
|
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||||
|
item.fromUserName
|
||||||
|
}}</NuxtLink>
|
||||||
|
在文章
|
||||||
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
|
item.postTitle
|
||||||
|
}}</NuxtLink>
|
||||||
|
中打赏了你,获得 {{ item.amount }} 积分
|
||||||
|
</template>
|
||||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,6 +269,8 @@ const iconMap = {
|
|||||||
FEATURE: 'star',
|
FEATURE: 'star',
|
||||||
LOTTERY_JOIN: 'medal-one',
|
LOTTERY_JOIN: 'medal-one',
|
||||||
LOTTERY_REWARD: 'fireworks',
|
LOTTERY_REWARD: 'fireworks',
|
||||||
|
DONATE_SENT: 'paper-money-two',
|
||||||
|
DONATE_RECEIVED: 'paper-money-two',
|
||||||
POST_LIKE_CANCELLED: 'clear-icon',
|
POST_LIKE_CANCELLED: 'clear-icon',
|
||||||
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<div class="post-options-left">
|
<div class="post-options-left">
|
||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
|
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
|
|||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
|
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ const title = ref('')
|
|||||||
const content = ref('')
|
const content = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
|
const selectedVisibleScope = ref('ALL')
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
@@ -70,6 +73,7 @@ const loadPost = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
|
selectedVisibleScope.value = data.visibleScope
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -180,6 +184,7 @@ const submitPost = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value,
|
tagIds: selectedTags.value,
|
||||||
|
postVisibleScopeType:selectedVisibleScope.value
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
<template>
|
<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">
|
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +33,9 @@
|
|||||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||||
<div v-if="!rssExcluded" class="article-featured-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
|
<div
|
||||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||||
class="article-subscribe-button"
|
class="article-subscribe-button"
|
||||||
@@ -92,11 +111,29 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
<div class="article-option-container">
|
||||||
<div class="make-reaction-item copy-link" @click="copyPostLink">
|
<ReactionsGroup
|
||||||
|
ref="postReactionsGroupRef"
|
||||||
|
v-model="postReactions"
|
||||||
|
content-type="post"
|
||||||
|
:content-id="postId"
|
||||||
|
/>
|
||||||
|
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
|
||||||
|
</div>
|
||||||
|
<div class="article-footer-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: postLikedByMe }"
|
||||||
|
@click="togglePostLike"
|
||||||
|
>
|
||||||
|
<like v-if="!postLikedByMe" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-action copy-link" @click="copyPostLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,25 +184,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<vue-easy-lightbox
|
||||||
:visible="lightboxVisible"
|
:visible="lightboxVisible"
|
||||||
:index="lightboxIndex"
|
:index="lightboxIndex"
|
||||||
@@ -196,6 +214,7 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
|||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
|
import DonateGroup from '~/components/DonateGroup.vue'
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import PostLottery from '~/components/PostLottery.vue'
|
import PostLottery from '~/components/PostLottery.vue'
|
||||||
import PostPoll from '~/components/PostPoll.vue'
|
import PostPoll from '~/components/PostPoll.vue'
|
||||||
@@ -209,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
import { Lock } from '@icon-park/vue-next'
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -222,7 +242,26 @@ const author = ref('')
|
|||||||
const postContent = ref('')
|
const postContent = ref('')
|
||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = 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 postReactions = ref([])
|
||||||
|
const postReactionsGroupRef = ref(null)
|
||||||
|
const postLikeCount = computed(
|
||||||
|
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
|
||||||
|
)
|
||||||
|
const postLikedByMe = computed(() =>
|
||||||
|
postReactions.value.some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const togglePostLike = () => {
|
||||||
|
postReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const changeLogs = ref([])
|
const changeLogs = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
@@ -366,9 +405,9 @@ const changeLogIcon = (l) => {
|
|||||||
return 'unlock'
|
return 'unlock'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'PINNED') {
|
} else if (l.type === 'PINNED') {
|
||||||
if(l.newPinnedAt){
|
if (l.newPinnedAt) {
|
||||||
return 'pin'
|
return 'pin'
|
||||||
}else{
|
} else {
|
||||||
return 'clear-icon'
|
return 'clear-icon'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'FEATURED') {
|
} else if (l.type === 'FEATURED') {
|
||||||
@@ -377,10 +416,20 @@ const changeLogIcon = (l) => {
|
|||||||
} else {
|
} else {
|
||||||
return 'dislike'
|
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') {
|
} else if (l.type === 'VOTE_RESULT') {
|
||||||
return 'check-one'
|
return 'check-one'
|
||||||
} else if (l.type === 'LOTTERY_RESULT') {
|
} else if (l.type === 'LOTTERY_RESULT') {
|
||||||
return 'gift'
|
return 'gift'
|
||||||
|
} else if (l.type === 'DONATE') {
|
||||||
|
return 'financing'
|
||||||
} else {
|
} else {
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
@@ -405,6 +454,9 @@ const mapChangeLog = (l) => ({
|
|||||||
newCategory: l.newCategory,
|
newCategory: l.newCategory,
|
||||||
oldTags: l.oldTags,
|
oldTags: l.oldTags,
|
||||||
newTags: l.newTags,
|
newTags: l.newTags,
|
||||||
|
oldVisibleScope: l.oldVisibleScope,
|
||||||
|
newVisibleScope: l.newVisibleScope,
|
||||||
|
amount: l.amount,
|
||||||
icon: changeLogIcon(l),
|
icon: changeLogIcon(l),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -463,15 +515,27 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchTimeline()
|
fetchTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
|
const token = getToken()
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
})
|
||||||
const {
|
const {
|
||||||
data: postData,
|
data: postData,
|
||||||
pending: pendingPost,
|
pending: pendingPost,
|
||||||
error: postError,
|
error: postError,
|
||||||
refresh: refreshPost,
|
refresh: refreshPost,
|
||||||
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
} = await useAsyncData(
|
||||||
server: true,
|
`post-${postId}`,
|
||||||
lazy: false,
|
async () => {
|
||||||
})
|
try {
|
||||||
|
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
||||||
|
} catch (err) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: false,
|
||||||
|
lazy: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
@@ -485,6 +549,7 @@ watchEffect(() => {
|
|||||||
title.value = data.title
|
title.value = data.title
|
||||||
category.value = data.category
|
category.value = data.category
|
||||||
tags.value = data.tags || []
|
tags.value = data.tags || []
|
||||||
|
visibleScope.value = data.visibleScope || 'ALL'
|
||||||
postReactions.value = data.reactions || []
|
postReactions.value = data.reactions || []
|
||||||
subscribed.value = !!data.subscribed
|
subscribed.value = !!data.subscribed
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
@@ -901,7 +966,7 @@ onMounted(async () => {
|
|||||||
<style>
|
<style>
|
||||||
.post-page-container {
|
.post-page-container {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -914,9 +979,10 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
|
position: relative;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: calc(85% - 40px);
|
width: calc(100% - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text p code {
|
.info-content-text p code {
|
||||||
@@ -968,6 +1034,35 @@ onMounted(async () => {
|
|||||||
opacity: 0.5;
|
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 {
|
.user-avatar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1072,7 +1167,7 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button,
|
.article-gray-button,
|
||||||
.article-subscribe-button-text,
|
.article-subscribe-button-text,
|
||||||
.article-featured-button,
|
.article-featured-button,
|
||||||
.article-unsubscribe-button-text {
|
.article-unsubscribe-button-text {
|
||||||
@@ -1125,7 +1220,7 @@ onMounted(async () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-closed-button {
|
.article-gray-button {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: gray;
|
color: gray;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -1245,35 +1340,61 @@ onMounted(async () => {
|
|||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.article-option-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer-item-container {
|
.article-footer-actions {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-viewer-item {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-container {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-link:hover {
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.copy-link:hover {
|
||||||
background-color: #e2e2e2;
|
background-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,6 +1402,76 @@ onMounted(async () => {
|
|||||||
position: relative;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
@@ -1322,6 +1513,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
margin-bottom: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { loadCurrentUser, setToken } from '~/utils/auth'
|
import { setToken } from '~/utils/auth'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -172,7 +172,6 @@ const verifyCode = async () => {
|
|||||||
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
||||||
toast.success('注册成功')
|
toast.success('注册成功')
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
loadCurrentUser()
|
|
||||||
navigateTo('/', { replace: true })
|
navigateTo('/', { replace: true })
|
||||||
} else if (data.reason_code === 'VERIFIED') {
|
} else if (data.reason_code === 'VERIFIED') {
|
||||||
if (registerMode.value === 'WHITELIST') {
|
if (registerMode.value === 'WHITELIST') {
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ import {
|
|||||||
Dislike,
|
Dislike,
|
||||||
CheckOne,
|
CheckOne,
|
||||||
Share,
|
Share,
|
||||||
|
Financing,
|
||||||
|
Hands,
|
||||||
|
PreviewCloseOne,
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -163,4 +166,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('Dislike', Dislike)
|
nuxtApp.vueApp.component('Dislike', Dislike)
|
||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
|
nuxtApp.vueApp.component('Financing', Financing)
|
||||||
|
nuxtApp.vueApp.component('Hands', Hands)
|
||||||
|
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,33 +1,28 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
const USER_ID_KEY = 'userId'
|
|
||||||
const USERNAME_KEY = 'username'
|
|
||||||
const ROLE_KEY = 'role'
|
|
||||||
|
|
||||||
export const authState = reactive({
|
export const authState = reactive({
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
role: null,
|
role: null,
|
||||||
|
avatar: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
authState.loggedIn =
|
authState.loggedIn =
|
||||||
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||||
authState.userId = localStorage.getItem(USER_ID_KEY)
|
|
||||||
authState.username = localStorage.getItem(USERNAME_KEY)
|
|
||||||
authState.role = localStorage.getItem(ROLE_KEY)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToken(token) {
|
export async function setToken(token) {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
authState.loggedIn = true
|
await loadCurrentUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,26 +34,20 @@ export function clearToken() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUserInfo({ id, username }) {
|
export function setUserInfo(user) {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
authState.userId = id
|
authState.userId = user.id
|
||||||
authState.username = username
|
authState.username = user.username
|
||||||
if (arguments[0] && arguments[0].role) {
|
authState.avatar = user.avatar
|
||||||
authState.role = arguments[0].role
|
authState.role = user.role
|
||||||
localStorage.setItem(ROLE_KEY, arguments[0].role)
|
|
||||||
}
|
|
||||||
if (id !== undefined && id !== null) localStorage.setItem(USER_ID_KEY, id)
|
|
||||||
if (username) localStorage.setItem(USERNAME_KEY, username)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearUserInfo() {
|
export function clearUserInfo() {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.removeItem(USER_ID_KEY)
|
|
||||||
localStorage.removeItem(USERNAME_KEY)
|
|
||||||
localStorage.removeItem(ROLE_KEY)
|
|
||||||
authState.userId = null
|
authState.userId = null
|
||||||
authState.username = null
|
authState.username = null
|
||||||
|
authState.avatar = null
|
||||||
authState.role = null
|
authState.role = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,9 +71,11 @@ export async function fetchCurrentUser() {
|
|||||||
export async function loadCurrentUser() {
|
export async function loadCurrentUser() {
|
||||||
const user = await fetchCurrentUser()
|
const user = await fetchCurrentUser()
|
||||||
if (user) {
|
if (user) {
|
||||||
setUserInfo({ id: user.id, username: user.username, role: user.role })
|
setUserInfo(user)
|
||||||
|
} else {
|
||||||
|
clearUserInfo()
|
||||||
}
|
}
|
||||||
return user
|
authState.loggedIn = user !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLogin() {
|
export function isLogin() {
|
||||||
@@ -100,10 +91,12 @@ export async function checkToken() {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
authState.loggedIn = res.ok
|
if (res.ok) {
|
||||||
return res.ok
|
await setToken(token)
|
||||||
|
} else {
|
||||||
|
clearToken()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
authState.loggedIn = false
|
clearToken()
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(inviteToken = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
@@ -47,7 +47,6 @@ export async function discordExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(inviteToken = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
@@ -45,7 +45,6 @@ export async function githubExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export async function googleGetIdToken() {
|
export async function googleGetIdToken() {
|
||||||
@@ -79,7 +79,6 @@ export async function googleAuthWithToken(
|
|||||||
|
|
||||||
if (res.ok && data && data.token) {
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
if (typeof redirect_success === 'function') redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
|||||||
@@ -265,3 +265,24 @@ export function stripMarkdownLength(text, length) {
|
|||||||
}
|
}
|
||||||
return plain.slice(0, length) + '...'
|
return plain.slice(0, length) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 朴素文本带贴吧表情
|
||||||
|
export function stripMarkdownWithTiebaMoji(text, length){
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,9 +28,12 @@ const iconMap = {
|
|||||||
POLL_VOTE: 'ChartHistogram',
|
POLL_VOTE: 'ChartHistogram',
|
||||||
POLL_RESULT_OWNER: 'RankingList',
|
POLL_RESULT_OWNER: 'RankingList',
|
||||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
|
||||||
|
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
|
||||||
MENTION: 'HashtagKey',
|
MENTION: 'HashtagKey',
|
||||||
POST_DELETED: 'ClearIcon',
|
POST_DELETED: 'ClearIcon',
|
||||||
POST_FEATURED: 'Star',
|
POST_FEATURED: 'Star',
|
||||||
|
DONATION: 'PaperMoneyTwo',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUnreadCount() {
|
export async function fetchUnreadCount() {
|
||||||
@@ -253,7 +256,9 @@ function createFetchNotifications() {
|
|||||||
} else if (
|
} else if (
|
||||||
n.type === 'POLL_VOTE' ||
|
n.type === 'POLL_VOTE' ||
|
||||||
n.type === 'POLL_RESULT_OWNER' ||
|
n.type === 'POLL_RESULT_OWNER' ||
|
||||||
n.type === 'POLL_RESULT_PARTICIPANT'
|
n.type === 'POLL_RESULT_PARTICIPANT' ||
|
||||||
|
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
|
||||||
|
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
|
||||||
) {
|
) {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -334,6 +339,18 @@ function createFetchNotifications() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else if (n.type === 'DONATION') {
|
||||||
|
arr.push({
|
||||||
|
...n,
|
||||||
|
src: n.fromUser ? n.fromUser.avatar : null,
|
||||||
|
icon: n.fromUser ? undefined : iconMap[n.type],
|
||||||
|
iconClick: () => {
|
||||||
|
if (n.post) {
|
||||||
|
markNotificationRead(n.id)
|
||||||
|
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (n.type === 'REGISTER_REQUEST') {
|
} else if (n.type === 'REGISTER_REQUEST') {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function telegramAuthorize(inviteToken = '') {
|
export function telegramAuthorize(inviteToken = '') {
|
||||||
@@ -34,7 +34,6 @@ export async function telegramExchange(authData, inviteToken = '', reason = '')
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken, loadCurrentUser } from './auth'
|
import { setToken } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
@@ -99,7 +99,6 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
await loadCurrentUser()
|
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
41
mcp/README.md
Normal file
41
mcp/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# OpenIsle MCP Server
|
||||||
|
|
||||||
|
This package provides a [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server
|
||||||
|
that exposes OpenIsle's search capabilities as MCP tools. The initial release focuses on the
|
||||||
|
global search endpoint so the agent ecosystem can retrieve relevant posts, users, tags, and
|
||||||
|
other resources.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The server is configured through environment variables (all prefixed with `OPENISLE_MCP_`):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BACKEND_BASE_URL` | `http://springboot:8080` | Base URL of the OpenIsle backend. |
|
||||||
|
| `PORT` | `8085` | TCP port when running with the `streamable-http` transport. |
|
||||||
|
| `HOST` | `0.0.0.0` | Interface to bind when serving HTTP. |
|
||||||
|
| `TRANSPORT` | `streamable-http` | Transport to use (`stdio`, `sse`, or `streamable-http`). |
|
||||||
|
| `REQUEST_TIMEOUT` | `10.0` | Timeout (seconds) for backend HTTP requests. |
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install .
|
||||||
|
OPENISLE_MCP_BACKEND_BASE_URL="http://localhost:8080" openisle-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the server listens on port `8085` and serves MCP over Streamable HTTP.
|
||||||
|
|
||||||
|
## Available tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `search` | Perform a global search against the OpenIsle backend. |
|
||||||
|
| `reply_to_post` | Create a new comment on a post using a JWT token. |
|
||||||
|
| `reply_to_comment` | Reply to an existing comment using a JWT token. |
|
||||||
|
| `recent_posts` | Retrieve posts created within the last *N* minutes. |
|
||||||
|
|
||||||
|
The tools return structured data mirroring the backend DTOs, including highlighted snippets for
|
||||||
|
search results, the full comment payload for post replies and comment replies, and detailed
|
||||||
|
metadata for recent posts.
|
||||||
|
|
||||||
27
mcp/pyproject.toml
Normal file
27
mcp/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.25"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "openisle-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Model Context Protocol server exposing OpenIsle search capabilities."
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{ name = "OpenIsle", email = "engineering@openisle.example" }]
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"mcp>=1.19.0",
|
||||||
|
"httpx>=0.28,<0.29",
|
||||||
|
"pydantic>=2.12,<3",
|
||||||
|
"pydantic-settings>=2.11,<3"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
openisle-mcp = "openisle_mcp.server:main"
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
packages = ["src/openisle_mcp"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
6
mcp/src/openisle_mcp/__init__.py
Normal file
6
mcp/src/openisle_mcp/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""OpenIsle MCP server package."""
|
||||||
|
|
||||||
|
from .config import Settings, get_settings
|
||||||
|
|
||||||
|
__all__ = ["Settings", "get_settings"]
|
||||||
|
|
||||||
66
mcp/src/openisle_mcp/config.py
Normal file
66
mcp/src/openisle_mcp/config.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""Application configuration helpers for the OpenIsle MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, SecretStr
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Configuration for the MCP server."""
|
||||||
|
|
||||||
|
backend_base_url: AnyHttpUrl = Field(
|
||||||
|
"http://springboot:8080",
|
||||||
|
description="Base URL for the OpenIsle backend service.",
|
||||||
|
)
|
||||||
|
host: str = Field(
|
||||||
|
"0.0.0.0",
|
||||||
|
description="Host interface to bind when running with HTTP transports.",
|
||||||
|
)
|
||||||
|
port: int = Field(
|
||||||
|
8085,
|
||||||
|
ge=1,
|
||||||
|
le=65535,
|
||||||
|
description="TCP port for HTTP transports.",
|
||||||
|
)
|
||||||
|
transport: Literal["stdio", "sse", "streamable-http"] = Field(
|
||||||
|
"streamable-http",
|
||||||
|
description="MCP transport to use when running the server.",
|
||||||
|
)
|
||||||
|
request_timeout: float = Field(
|
||||||
|
10.0,
|
||||||
|
gt=0,
|
||||||
|
description="Timeout (seconds) for backend search requests.",
|
||||||
|
)
|
||||||
|
access_token: SecretStr | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional JWT bearer token used for authenticated backend calls. "
|
||||||
|
"When set, tools that support authentication will use this token "
|
||||||
|
"automatically unless an explicit token override is provided."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
log_level: str = Field(
|
||||||
|
"INFO",
|
||||||
|
description=(
|
||||||
|
"Logging level for the MCP server (e.g. DEBUG, INFO, WARNING)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="OPENISLE_MCP_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Return cached application settings."""
|
||||||
|
|
||||||
|
return Settings()
|
||||||
333
mcp/src/openisle_mcp/schemas.py
Normal file
333
mcp/src/openisle_mcp/schemas.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""Pydantic models describing tool inputs and outputs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
"""A single search result entry."""
|
||||||
|
|
||||||
|
type: str = Field(description="Entity type for the result (post, user, tag, etc.).")
|
||||||
|
id: Optional[int] = Field(default=None, description="Identifier of the matched entity.")
|
||||||
|
text: Optional[str] = Field(default=None, description="Primary text associated with the result.")
|
||||||
|
sub_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="subText",
|
||||||
|
description="Secondary text, e.g. a username or excerpt.",
|
||||||
|
)
|
||||||
|
extra: Optional[str] = Field(default=None, description="Additional contextual information.")
|
||||||
|
post_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="postId",
|
||||||
|
description="Associated post identifier when relevant.",
|
||||||
|
)
|
||||||
|
highlighted_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedText",
|
||||||
|
description="Highlighted snippet of the primary text if available.",
|
||||||
|
)
|
||||||
|
highlighted_sub_text: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedSubText",
|
||||||
|
description="Highlighted snippet of the secondary text if available.",
|
||||||
|
)
|
||||||
|
highlighted_extra: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="highlightedExtra",
|
||||||
|
description="Highlighted snippet of extra information if available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
"""Structured response returned by the search tool."""
|
||||||
|
|
||||||
|
keyword: str = Field(description="The keyword that was searched.")
|
||||||
|
total: int = Field(description="Total number of matches returned by the backend.")
|
||||||
|
results: list[SearchResultItem] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Ordered collection of search results.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorInfo(BaseModel):
|
||||||
|
"""Summary of a post or comment author."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Author identifier.")
|
||||||
|
username: Optional[str] = Field(default=None, description="Author username.")
|
||||||
|
avatar: Optional[str] = Field(default=None, description="URL of the author's avatar.")
|
||||||
|
display_medal: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="displayMedal",
|
||||||
|
description="Medal displayed next to the author, when available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryInfo(BaseModel):
|
||||||
|
"""Basic information about a post category."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Category identifier.")
|
||||||
|
name: Optional[str] = Field(default=None, description="Category name.")
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
default=None, description="Human friendly description of the category."
|
||||||
|
)
|
||||||
|
icon: Optional[str] = Field(default=None, description="Icon URL associated with the category.")
|
||||||
|
small_icon: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="smallIcon",
|
||||||
|
description="Compact icon URL for the category.",
|
||||||
|
)
|
||||||
|
count: Optional[int] = Field(default=None, description="Number of posts within the category.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class TagInfo(BaseModel):
|
||||||
|
"""Details for a tag assigned to a post."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Tag identifier.")
|
||||||
|
name: Optional[str] = Field(default=None, description="Tag name.")
|
||||||
|
description: Optional[str] = Field(default=None, description="Description of the tag.")
|
||||||
|
icon: Optional[str] = Field(default=None, description="Icon URL for the tag.")
|
||||||
|
small_icon: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="smallIcon",
|
||||||
|
description="Compact icon URL for the tag.",
|
||||||
|
)
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="When the tag was created.",
|
||||||
|
)
|
||||||
|
count: Optional[int] = Field(default=None, description="Number of posts using the tag.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionInfo(BaseModel):
|
||||||
|
"""Representation of a reaction on a post or comment."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Reaction identifier.")
|
||||||
|
type: Optional[str] = Field(default=None, description="Reaction type (emoji, like, etc.).")
|
||||||
|
user: Optional[str] = Field(default=None, description="Username of the reacting user.")
|
||||||
|
post_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="postId",
|
||||||
|
description="Related post identifier when applicable.",
|
||||||
|
)
|
||||||
|
comment_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="commentId",
|
||||||
|
description="Related comment identifier when applicable.",
|
||||||
|
)
|
||||||
|
message_id: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="messageId",
|
||||||
|
description="Related message identifier when applicable.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward granted for the reaction, if any.")
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentData(BaseModel):
|
||||||
|
"""Comment information returned by the backend."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Comment identifier.")
|
||||||
|
content: Optional[str] = Field(default=None, description="Markdown content of the comment.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="Timestamp when the comment was created.",
|
||||||
|
)
|
||||||
|
pinned_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pinnedAt",
|
||||||
|
description="Timestamp when the comment was pinned, if applicable.",
|
||||||
|
)
|
||||||
|
author: Optional[AuthorInfo] = Field(default=None, description="Author of the comment.")
|
||||||
|
replies: list["CommentData"] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Nested replies associated with the comment.",
|
||||||
|
)
|
||||||
|
reactions: list[ReactionInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Reactions applied to the comment.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward gained by posting the comment.")
|
||||||
|
point_reward: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pointReward",
|
||||||
|
description="Points rewarded for the comment.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentReplyResult(BaseModel):
|
||||||
|
"""Structured response returned when replying to a comment."""
|
||||||
|
|
||||||
|
comment: CommentData = Field(description="Reply comment returned by the backend.")
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreateResult(BaseModel):
|
||||||
|
"""Structured response returned when creating a comment on a post."""
|
||||||
|
|
||||||
|
comment: CommentData = Field(description="Comment returned by the backend.")
|
||||||
|
|
||||||
|
|
||||||
|
class PostSummary(BaseModel):
|
||||||
|
"""Summary information for a post."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Post identifier.")
|
||||||
|
title: Optional[str] = Field(default=None, description="Title of the post.")
|
||||||
|
content: Optional[str] = Field(default=None, description="Excerpt or content of the post.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="When the post was created.",
|
||||||
|
)
|
||||||
|
author: Optional[AuthorInfo] = Field(default=None, description="Author who created the post.")
|
||||||
|
category: Optional[CategoryInfo] = Field(default=None, description="Category of the post.")
|
||||||
|
tags: list[TagInfo] = Field(default_factory=list, description="Tags assigned to the post.")
|
||||||
|
views: Optional[int] = Field(default=None, description="Total view count for the post.")
|
||||||
|
comment_count: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="commentCount",
|
||||||
|
description="Number of comments on the post.",
|
||||||
|
)
|
||||||
|
status: Optional[str] = Field(default=None, description="Workflow status of the post.")
|
||||||
|
pinned_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pinnedAt",
|
||||||
|
description="When the post was pinned, if ever.",
|
||||||
|
)
|
||||||
|
last_reply_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="lastReplyAt",
|
||||||
|
description="Timestamp of the most recent reply.",
|
||||||
|
)
|
||||||
|
reactions: list[ReactionInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Reactions received by the post.",
|
||||||
|
)
|
||||||
|
participants: list[AuthorInfo] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Users participating in the discussion.",
|
||||||
|
)
|
||||||
|
subscribed: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether the current user is subscribed to the post.",
|
||||||
|
)
|
||||||
|
reward: Optional[int] = Field(default=None, description="Reward granted for the post.")
|
||||||
|
point_reward: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="pointReward",
|
||||||
|
description="Points granted for the post.",
|
||||||
|
)
|
||||||
|
type: Optional[str] = Field(default=None, description="Type of the post.")
|
||||||
|
lottery: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Lottery information for the post."
|
||||||
|
)
|
||||||
|
poll: Optional[dict[str, Any]] = Field(
|
||||||
|
default=None, description="Poll information for the post."
|
||||||
|
)
|
||||||
|
rss_excluded: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="rssExcluded",
|
||||||
|
description="Whether the post is excluded from RSS feeds.",
|
||||||
|
)
|
||||||
|
closed: Optional[bool] = Field(default=None, description="Whether the post is closed for replies.")
|
||||||
|
visible_scope: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="visibleScope",
|
||||||
|
description="Visibility scope configuration for the post.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class RecentPostsResponse(BaseModel):
|
||||||
|
"""Structured response for the recent posts tool."""
|
||||||
|
|
||||||
|
minutes: int = Field(description="Time window, in minutes, used for the query.")
|
||||||
|
total: int = Field(description="Number of posts returned by the backend.")
|
||||||
|
posts: list[PostSummary] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Posts created within the requested time window.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CommentData.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
class PostDetail(PostSummary):
|
||||||
|
"""Detailed information for a single post, including comments."""
|
||||||
|
|
||||||
|
comments: list[CommentData] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Comments that belong to the post.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationData(BaseModel):
|
||||||
|
"""Unread notification payload returned by the backend."""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, description="Notification identifier.")
|
||||||
|
type: Optional[str] = Field(default=None, description="Type of the notification.")
|
||||||
|
post: Optional[PostSummary] = Field(
|
||||||
|
default=None, description="Post associated with the notification if applicable."
|
||||||
|
)
|
||||||
|
comment: Optional[CommentData] = Field(
|
||||||
|
default=None, description="Comment referenced by the notification when available."
|
||||||
|
)
|
||||||
|
parent_comment: Optional[CommentData] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="parentComment",
|
||||||
|
description="Parent comment for nested replies, when present.",
|
||||||
|
)
|
||||||
|
from_user: Optional[AuthorInfo] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="fromUser",
|
||||||
|
description="User who triggered the notification.",
|
||||||
|
)
|
||||||
|
reaction_type: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="reactionType",
|
||||||
|
description="Reaction type for reaction-based notifications.",
|
||||||
|
)
|
||||||
|
content: Optional[str] = Field(
|
||||||
|
default=None, description="Additional content or message for the notification."
|
||||||
|
)
|
||||||
|
approved: Optional[bool] = Field(
|
||||||
|
default=None, description="Approval status for moderation notifications."
|
||||||
|
)
|
||||||
|
read: Optional[bool] = Field(default=None, description="Whether the notification is read.")
|
||||||
|
created_at: Optional[datetime] = Field(
|
||||||
|
default=None,
|
||||||
|
alias="createdAt",
|
||||||
|
description="Timestamp when the notification was created.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadNotificationsResponse(BaseModel):
|
||||||
|
"""Structured response for unread notification queries."""
|
||||||
|
|
||||||
|
page: int = Field(description="Requested page index for the unread notifications.")
|
||||||
|
size: int = Field(description="Requested page size for the unread notifications.")
|
||||||
|
total: int = Field(description="Number of unread notifications returned in this page.")
|
||||||
|
notifications: list[NotificationData] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Unread notifications returned by the backend.",
|
||||||
|
)
|
||||||
268
mcp/src/openisle_mcp/search_client.py
Normal file
268
mcp/src/openisle_mcp/search_client.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""HTTP client helpers for talking to the OpenIsle backend endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchClient:
|
||||||
|
"""Client for calling the OpenIsle HTTP APIs used by the MCP server."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = 10.0,
|
||||||
|
access_token: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._base_url = base_url.rstrip("/")
|
||||||
|
self._timeout = timeout
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._access_token = self._sanitize_token(access_token)
|
||||||
|
|
||||||
|
def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None:
|
||||||
|
logger.debug(
|
||||||
|
"Creating httpx.AsyncClient for base URL %s with timeout %.2fs",
|
||||||
|
self._base_url,
|
||||||
|
self._timeout,
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self._base_url,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_token(token: str | None) -> str | None:
|
||||||
|
if token is None:
|
||||||
|
return None
|
||||||
|
stripped = token.strip()
|
||||||
|
return stripped or None
|
||||||
|
|
||||||
|
def update_access_token(self, token: str | None) -> None:
|
||||||
|
"""Update the default access token used for authenticated requests."""
|
||||||
|
|
||||||
|
self._access_token = self._sanitize_token(token)
|
||||||
|
if self._access_token:
|
||||||
|
logger.debug("Configured default access token for SearchClient requests.")
|
||||||
|
else:
|
||||||
|
logger.debug("Cleared default access token for SearchClient requests.")
|
||||||
|
|
||||||
|
def _resolve_token(self, token: str | None) -> str | None:
|
||||||
|
candidate = self._sanitize_token(token)
|
||||||
|
if candidate is not None:
|
||||||
|
return candidate
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
def _require_token(self, token: str | None) -> str:
|
||||||
|
resolved = self._resolve_token(token)
|
||||||
|
if resolved is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Authenticated request requires an access token but none was provided."
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _build_headers(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
token: str | None = None,
|
||||||
|
accept: str = "application/json",
|
||||||
|
include_json: bool = False,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
headers: dict[str, str] = {"Accept": accept}
|
||||||
|
resolved = self._resolve_token(token)
|
||||||
|
if resolved:
|
||||||
|
headers["Authorization"] = f"Bearer {resolved}"
|
||||||
|
if include_json:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def global_search(self, keyword: str) -> list[dict[str, Any]]:
|
||||||
|
"""Call the global search endpoint and return the parsed JSON payload."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
logger.debug("Calling global search with keyword=%s", keyword)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/search/global",
|
||||||
|
params={"keyword": keyword},
|
||||||
|
headers=self._build_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(f"Unexpected response format from search endpoint: {formatted}")
|
||||||
|
logger.info(
|
||||||
|
"Global search returned %d results for keyword '%s'",
|
||||||
|
len(payload),
|
||||||
|
keyword,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def reply_to_comment(
|
||||||
|
self,
|
||||||
|
comment_id: int,
|
||||||
|
token: str,
|
||||||
|
content: str,
|
||||||
|
captcha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Reply to an existing comment and return the created reply."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||||
|
payload: dict[str, Any] = {"content": content}
|
||||||
|
if captcha is not None:
|
||||||
|
stripped_captcha = captcha.strip()
|
||||||
|
if stripped_captcha:
|
||||||
|
payload["captcha"] = stripped_captcha
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Posting reply to comment_id=%s (captcha=%s)",
|
||||||
|
comment_id,
|
||||||
|
bool(captcha),
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/comments/{comment_id}/replies",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info("Reply to comment_id=%s succeeded with id=%s", comment_id, body.get("id"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def reply_to_post(
|
||||||
|
self,
|
||||||
|
post_id: int,
|
||||||
|
token: str,
|
||||||
|
content: str,
|
||||||
|
captcha: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a comment on a post and return the backend payload."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
headers = self._build_headers(token=resolved_token, include_json=True)
|
||||||
|
payload: dict[str, Any] = {"content": content}
|
||||||
|
if captcha is not None:
|
||||||
|
stripped_captcha = captcha.strip()
|
||||||
|
if stripped_captcha:
|
||||||
|
payload["captcha"] = stripped_captcha
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Posting comment to post_id=%s (captcha=%s)",
|
||||||
|
post_id,
|
||||||
|
bool(captcha),
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/posts/{post_id}/comments",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info("Reply to post_id=%s succeeded with id=%s", post_id, body.get("id"))
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def recent_posts(self, minutes: int) -> list[dict[str, Any]]:
|
||||||
|
"""Return posts created within the given timeframe."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
logger.debug(
|
||||||
|
"Fetching recent posts within last %s minutes",
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/posts/recent",
|
||||||
|
params={"minutes": minutes},
|
||||||
|
headers=self._build_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected response format from recent posts endpoint: {formatted}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Fetched %d recent posts for window=%s minutes",
|
||||||
|
len(payload),
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def get_post(self, post_id: int, token: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Retrieve the detailed payload for a single post."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
headers = self._build_headers(token=token)
|
||||||
|
logger.debug("Fetching post details for post_id=%s", post_id)
|
||||||
|
response = await client.get(f"/api/posts/{post_id}", headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = self._ensure_dict(response.json())
|
||||||
|
logger.info(
|
||||||
|
"Retrieved post_id=%s successfully with %d top-level comments",
|
||||||
|
post_id,
|
||||||
|
len(body.get("comments", []) if isinstance(body.get("comments"), list) else []),
|
||||||
|
)
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def list_unread_notifications(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
page: int = 0,
|
||||||
|
size: int = 30,
|
||||||
|
token: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Return unread notifications for the authenticated user."""
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
resolved_token = self._require_token(token)
|
||||||
|
logger.debug(
|
||||||
|
"Fetching unread notifications with page=%s, size=%s",
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
response = await client.get(
|
||||||
|
"/api/notifications/unread",
|
||||||
|
params={"page": page, "size": size},
|
||||||
|
headers=self._build_headers(token=resolved_token),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
formatted = json.dumps(payload, ensure_ascii=False)[:200]
|
||||||
|
raise ValueError(
|
||||||
|
"Unexpected response format from unread notifications endpoint: "
|
||||||
|
f"{formatted}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Fetched %d unread notifications (page=%s, size=%s)",
|
||||||
|
len(payload),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
return [self._ensure_dict(entry) for entry in payload]
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Dispose of the underlying HTTP client."""
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
logger.debug("Closed httpx.AsyncClient for SearchClient.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_dict(entry: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"Expected JSON object, got: {type(entry)!r}")
|
||||||
|
return entry
|
||||||
591
mcp/src/openisle_mcp/server.py
Normal file
591
mcp/src/openisle_mcp/server.py
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
"""Entry point for running the OpenIsle MCP server."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from pydantic import Field as PydanticField
|
||||||
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
|
from .config import get_settings
|
||||||
|
from .schemas import (
|
||||||
|
CommentCreateResult,
|
||||||
|
CommentData,
|
||||||
|
CommentReplyResult,
|
||||||
|
NotificationData,
|
||||||
|
UnreadNotificationsResponse,
|
||||||
|
PostDetail,
|
||||||
|
PostSummary,
|
||||||
|
RecentPostsResponse,
|
||||||
|
SearchResponse,
|
||||||
|
SearchResultItem,
|
||||||
|
)
|
||||||
|
from .search_client import SearchClient
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if not logging.getLogger().handlers:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.getLogger().setLevel(
|
||||||
|
getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
search_client = SearchClient(
|
||||||
|
str(settings.backend_base_url),
|
||||||
|
timeout=settings.request_timeout,
|
||||||
|
access_token=(
|
||||||
|
settings.access_token.get_secret_value()
|
||||||
|
if settings.access_token is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTokenManager:
|
||||||
|
"""Cache JWT access tokens on a per-session basis."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tokens: WeakKeyDictionary[Any, str] = WeakKeyDictionary()
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self, ctx: Context | None, token: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Resolve and optionally persist the token for the current session."""
|
||||||
|
|
||||||
|
session = self._get_session(ctx)
|
||||||
|
|
||||||
|
if isinstance(token, str):
|
||||||
|
stripped = token.strip()
|
||||||
|
if stripped:
|
||||||
|
if session is not None:
|
||||||
|
self._tokens[session] = stripped
|
||||||
|
logger.debug(
|
||||||
|
"Stored JWT token for session %s.",
|
||||||
|
self._describe_session(session),
|
||||||
|
)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
if session is not None and session in self._tokens:
|
||||||
|
logger.debug(
|
||||||
|
"Clearing stored JWT token for session %s due to empty input.",
|
||||||
|
self._describe_session(session),
|
||||||
|
)
|
||||||
|
del self._tokens[session]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
cached = self._tokens.get(session)
|
||||||
|
if cached:
|
||||||
|
logger.debug(
|
||||||
|
"Reusing cached JWT token for session %s.",
|
||||||
|
self._describe_session(session),
|
||||||
|
)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_session(ctx: Context | None) -> Any | None:
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return ctx.session
|
||||||
|
except Exception: # pragma: no cover - defensive guard
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _describe_session(session: Any) -> str:
|
||||||
|
identifier = getattr(session, "mcp_session_id", None)
|
||||||
|
if isinstance(identifier, str) and identifier:
|
||||||
|
return identifier
|
||||||
|
return hex(id(session))
|
||||||
|
|
||||||
|
|
||||||
|
session_token_manager = SessionTokenManager()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastMCP):
|
||||||
|
"""Lifecycle hook that disposes shared resources when the server stops."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug("OpenIsle MCP server lifespan started.")
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
logger.debug("Disposing shared SearchClient instance.")
|
||||||
|
await search_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastMCP(
|
||||||
|
name="openisle-mcp",
|
||||||
|
instructions=(
|
||||||
|
"Use this server to search OpenIsle content, reply to posts and comments with "
|
||||||
|
"session-managed authentication, retrieve details for a specific post, list posts created "
|
||||||
|
"within a recent time window, and review unread notification messages."
|
||||||
|
),
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="search",
|
||||||
|
description="Perform a global search across OpenIsle resources.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def search(
|
||||||
|
keyword: Annotated[str, PydanticField(description="Keyword to search for.")],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> SearchResponse:
|
||||||
|
"""Call the OpenIsle global search endpoint and return structured results."""
|
||||||
|
|
||||||
|
sanitized = keyword.strip()
|
||||||
|
if not sanitized:
|
||||||
|
raise ValueError("Keyword must not be empty.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Received search request for keyword='%s'", sanitized)
|
||||||
|
raw_results = await search_client.global_search(sanitized)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while searching for '{sanitized}'."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend search service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = [SearchResultItem.model_validate(entry) for entry in raw_results]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the OpenIsle backend search endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(f"Search keyword '{sanitized}' returned {len(results)} results.")
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d search results for keyword='%s'",
|
||||||
|
len(results),
|
||||||
|
sanitized,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(keyword=sanitized, total=len(results), results=results)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="reply_to_post",
|
||||||
|
description="Create a comment on a post using session authentication.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def reply_to_post(
|
||||||
|
post_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the post being replied to."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Markdown content of the reply."),
|
||||||
|
],
|
||||||
|
captcha: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional captcha solution if the backend requires it.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> CommentCreateResult:
|
||||||
|
"""Create a comment on a post and return the backend payload."""
|
||||||
|
|
||||||
|
sanitized_content = content.strip()
|
||||||
|
if not sanitized_content:
|
||||||
|
raise ValueError("Reply content must not be empty.")
|
||||||
|
|
||||||
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
resolved_token = session_token_manager.resolve(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Creating reply for post_id=%s (captcha=%s)",
|
||||||
|
post_id,
|
||||||
|
bool(sanitized_captcha),
|
||||||
|
)
|
||||||
|
raw_comment = await search_client.reply_to_post(
|
||||||
|
post_id,
|
||||||
|
resolved_token,
|
||||||
|
sanitized_content,
|
||||||
|
sanitized_captcha,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 401:
|
||||||
|
message = (
|
||||||
|
"Authentication failed while replying to post "
|
||||||
|
f"{post_id}. Please verify the token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = (
|
||||||
|
"The provided token is not authorized to reply to post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
elif status_code == 404:
|
||||||
|
message = f"Post {post_id} was not found."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while replying to post {post_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"Unable to reach OpenIsle backend comment service: "
|
||||||
|
f"{exc}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = CommentData.model_validate(raw_comment)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the post comment endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
"Reply created successfully for post "
|
||||||
|
f"{post_id}."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated reply comment payload for post_id=%s (comment_id=%s)",
|
||||||
|
post_id,
|
||||||
|
comment.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentCreateResult(comment=comment)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="reply_to_comment",
|
||||||
|
description="Reply to an existing comment using session authentication.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def reply_to_comment(
|
||||||
|
comment_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the comment being replied to."),
|
||||||
|
],
|
||||||
|
content: Annotated[
|
||||||
|
str,
|
||||||
|
PydanticField(description="Markdown content of the reply."),
|
||||||
|
],
|
||||||
|
captcha: Annotated[
|
||||||
|
str | None,
|
||||||
|
PydanticField(
|
||||||
|
default=None,
|
||||||
|
description="Optional captcha solution if the backend requires it.",
|
||||||
|
),
|
||||||
|
] = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> CommentReplyResult:
|
||||||
|
"""Create a reply for a comment and return the backend payload."""
|
||||||
|
|
||||||
|
sanitized_content = content.strip()
|
||||||
|
if not sanitized_content:
|
||||||
|
raise ValueError("Reply content must not be empty.")
|
||||||
|
|
||||||
|
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
||||||
|
|
||||||
|
resolved_token = session_token_manager.resolve(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Creating reply for comment_id=%s (captcha=%s)",
|
||||||
|
comment_id,
|
||||||
|
bool(sanitized_captcha),
|
||||||
|
)
|
||||||
|
raw_comment = await search_client.reply_to_comment(
|
||||||
|
comment_id,
|
||||||
|
resolved_token,
|
||||||
|
sanitized_content,
|
||||||
|
sanitized_captcha,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 401:
|
||||||
|
message = (
|
||||||
|
"Authentication failed while replying to comment "
|
||||||
|
f"{comment_id}. Please verify the token."
|
||||||
|
)
|
||||||
|
elif status_code == 403:
|
||||||
|
message = (
|
||||||
|
"The provided token is not authorized to reply to comment "
|
||||||
|
f"{comment_id}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while replying to comment {comment_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"Unable to reach OpenIsle backend comment service: "
|
||||||
|
f"{exc}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = CommentData.model_validate(raw_comment)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the reply comment endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
"Reply created successfully for comment "
|
||||||
|
f"{comment_id}."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated reply payload for comment_id=%s (reply_id=%s)",
|
||||||
|
comment_id,
|
||||||
|
comment.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentReplyResult(comment=comment)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="recent_posts",
|
||||||
|
description="Retrieve posts created in the last N minutes.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def recent_posts(
|
||||||
|
minutes: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(gt=0, le=1440, description="Time window in minutes to search for new posts."),
|
||||||
|
],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> RecentPostsResponse:
|
||||||
|
"""Fetch recent posts from the backend and return structured data."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Fetching recent posts for last %s minutes", minutes)
|
||||||
|
raw_posts = await search_client.recent_posts(minutes)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while fetching recent posts for the last {minutes} minutes."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend recent posts service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
posts = [PostSummary.model_validate(entry) for entry in raw_posts]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the recent posts endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
f"Found {len(posts)} posts created within the last {minutes} minutes."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d recent posts for window=%s minutes",
|
||||||
|
len(posts),
|
||||||
|
minutes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecentPostsResponse(minutes=minutes, total=len(posts), posts=posts)
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="get_post",
|
||||||
|
description="Retrieve detailed information for a single post.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def get_post(
|
||||||
|
post_id: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(ge=1, description="Identifier of the post to retrieve."),
|
||||||
|
],
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> PostDetail:
|
||||||
|
"""Fetch post details from the backend and validate the response."""
|
||||||
|
|
||||||
|
resolved_token = session_token_manager.resolve(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Fetching post details for post_id=%s", post_id)
|
||||||
|
raw_post = await search_client.get_post(post_id, resolved_token)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
status_code = exc.response.status_code
|
||||||
|
if status_code == 404:
|
||||||
|
message = f"Post {post_id} was not found."
|
||||||
|
elif status_code == 401:
|
||||||
|
message = "Authentication failed while retrieving the post."
|
||||||
|
elif status_code == 403:
|
||||||
|
message = "The provided token is not authorized to view this post."
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{status_code} while retrieving post {post_id}."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend post service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = PostDetail.model_validate(raw_post)
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the post detail endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(f"Retrieved post {post_id} successfully.")
|
||||||
|
logger.debug(
|
||||||
|
"Validated post payload for post_id=%s with %d comments",
|
||||||
|
post_id,
|
||||||
|
len(post.comments),
|
||||||
|
)
|
||||||
|
|
||||||
|
return post
|
||||||
|
|
||||||
|
|
||||||
|
@app.tool(
|
||||||
|
name="list_unread_messages",
|
||||||
|
description="List unread notification messages for the authenticated user.",
|
||||||
|
structured_output=True,
|
||||||
|
)
|
||||||
|
async def list_unread_messages(
|
||||||
|
page: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(
|
||||||
|
default=0,
|
||||||
|
ge=0,
|
||||||
|
description="Page number of unread notifications to retrieve.",
|
||||||
|
),
|
||||||
|
] = 0,
|
||||||
|
size: Annotated[
|
||||||
|
int,
|
||||||
|
PydanticField(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=100,
|
||||||
|
description="Number of unread notifications to include per page.",
|
||||||
|
),
|
||||||
|
] = 30,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> UnreadNotificationsResponse:
|
||||||
|
"""Retrieve unread notifications and return structured data."""
|
||||||
|
|
||||||
|
resolved_token = session_token_manager.resolve(ctx)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
"Fetching unread notifications (page=%s, size=%s)",
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
raw_notifications = await search_client.list_unread_notifications(
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
token=resolved_token,
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
||||||
|
message = (
|
||||||
|
"OpenIsle backend returned HTTP "
|
||||||
|
f"{exc.response.status_code} while fetching unread notifications."
|
||||||
|
)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
except httpx.RequestError as exc: # pragma: no cover - network errors
|
||||||
|
message = f"Unable to reach OpenIsle backend notification service: {exc}."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
notifications = [
|
||||||
|
NotificationData.model_validate(entry) for entry in raw_notifications
|
||||||
|
]
|
||||||
|
except ValidationError as exc:
|
||||||
|
message = "Received malformed data from the unread notifications endpoint."
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.error(message)
|
||||||
|
raise ValueError(message) from exc
|
||||||
|
|
||||||
|
total = len(notifications)
|
||||||
|
if ctx is not None:
|
||||||
|
await ctx.info(
|
||||||
|
f"Retrieved {total} unread notifications (page {page}, size {size})."
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Validated %d unread notifications for page=%s size=%s",
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
|
||||||
|
return UnreadNotificationsResponse(
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
total=total,
|
||||||
|
notifications=notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the MCP server using the configured transport."""
|
||||||
|
|
||||||
|
app.run(transport=settings.transport)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover - manual execution
|
||||||
|
main()
|
||||||
|
|
||||||
@@ -100,10 +100,28 @@ server {
|
|||||||
# auth_basic_user_file /etc/nginx/.htpasswd;
|
# auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
|
||||||
location ^~ /websocket/ {
|
location ^~ /websocket/ {
|
||||||
proxy_pass http://127.0.0.1:8084/;
|
proxy_pass http://127.0.0.1:8084/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /mcp {
|
||||||
|
proxy_pass http://127.0.0.1:8085;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ server {
|
|||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name staging.open-isle.com www.staging.open-isle.com;
|
server_name staging.open-isle.com www.staging.open-isle.com;
|
||||||
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
||||||
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
|
|
||||||
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
|
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
@@ -40,59 +37,13 @@ server {
|
|||||||
add_header X-Upstream $upstream_addr always;
|
add_header X-Upstream $upstream_addr always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1) 原生 WebSocket
|
|
||||||
location ^~ /api/ws {
|
|
||||||
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
# 升级所需
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
|
||||||
location ^~ /api/sockjs {
|
|
||||||
proxy_pass http://127.0.0.1:8081;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
|
|
||||||
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
|
||||||
# proxy_hide_header X-Frame-Options;
|
|
||||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------- API ----------
|
# ---------- API ----------
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8081/api/;
|
proxy_pass http://127.0.0.1:8081/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -109,7 +60,6 @@ server {
|
|||||||
proxy_cache_bypass 1;
|
proxy_cache_bypass 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
|
|
||||||
location ^~ /websocket/ {
|
location ^~ /websocket/ {
|
||||||
proxy_pass http://127.0.0.1:8083/;
|
proxy_pass http://127.0.0.1:8083/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -130,4 +80,24 @@ server {
|
|||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
location /mcp {
|
||||||
|
proxy_pass http://127.0.0.1:8086;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user