Compare commits

...

91 Commits

Author SHA1 Message Date
Tim
87677f5968 Merge pull request #1103 from nagisa77/codex/add-git-action-to-run-reply_bots.ts
Add scheduled workflow to run reply bots
2025-10-28 13:56:31 +08:00
Tim
fd93a2dc61 Add scheduled reply bot workflow 2025-10-28 13:56:18 +08:00
Tim
80f862a226 Merge pull request #1102 from nagisa77/codex/add-read-cleanup-interface-for-mcp
Add MCP support for clearing read notifications
2025-10-28 13:50:33 +08:00
Tim
26bb85f4d4 Add MCP support for clearing read notifications 2025-10-28 13:50:16 +08:00
tim
398b4b482f fix: prompt 完善 2025-10-28 13:00:42 +08:00
tim
2cfb302981 fix: add bot 2025-10-28 12:37:17 +08:00
Tim
e75bd76b71 Merge pull request #1101 from nagisa77/codex/fix-unread-notifications-data-format
Normalize null list payloads in notification schemas
2025-10-28 10:27:54 +08:00
Tim
99c3ac1837 Handle null list fields in notification schemas 2025-10-28 10:27:40 +08:00
tim
749ab560ff Revert "Cache MCP session JWT tokens"
This reverts commit 997dacdbe6.
2025-10-28 01:55:46 +08:00
tim
541ad4d149 Revert "Remove token parameters from MCP tools"
This reverts commit e585100625.
2025-10-28 01:55:41 +08:00
tim
03eb027ea4 Revert "Add MCP tool for setting session token"
This reverts commit 9dadaad5ba.
2025-10-28 01:55:36 +08:00
Tim
4194b2be91 Merge pull request #1100 from nagisa77/codex/add-initialization-tool-for-jwt-token
Add MCP tool for initializing session JWT tokens
2025-10-28 01:47:28 +08:00
Tim
9dadaad5ba Add MCP tool for setting session token 2025-10-28 01:47:16 +08:00
Tim
d4b3400c5f Merge pull request #1099 from nagisa77/codex/remove-token-parameters-from-mcp-api
Remove explicit token parameters from MCP tools
2025-10-28 01:32:18 +08:00
Tim
e585100625 Remove token parameters from MCP tools 2025-10-28 01:32:02 +08:00
Tim
e94471b53e Merge pull request #1098 from nagisa77/codex/store-accesstoken-as-jwt-token
Cache MCP session JWT tokens
2025-10-28 01:20:52 +08:00
Tim
997dacdbe6 Cache MCP session JWT tokens 2025-10-28 01:20:32 +08:00
Tim
c01349a436 Merge pull request #1097 from nagisa77/codex/improve-python-mcp-logs
Improve MCP logging and add unread notification tool
2025-10-28 01:01:47 +08:00
Tim
4cf48f9157 Enhance MCP logging and add unread message tool 2025-10-28 01:01:25 +08:00
Tim
796afbe612 Merge pull request #1096 from nagisa77/codex/add-reply-support-for-posts
Add MCP support for replying to posts
2025-10-27 20:24:59 +08:00
Tim
dca14390ca Add MCP tool for replying to posts 2025-10-27 20:24:47 +08:00
Tim
39875acd35 Merge pull request #1095 from nagisa77/codex/add-post-query-interface-in-mcp-iq4fxi
Add MCP tool for retrieving post details
2025-10-27 20:19:29 +08:00
Tim
62edc75735 feat(mcp): add post detail retrieval tool 2025-10-27 20:19:17 +08:00
Tim
26ca9fc916 Merge pull request #1093 from nagisa77/codex/add-reply-and-recent-post-query-apis 2025-10-27 16:13:22 +08:00
Tim
cad70c23b3 feat(mcp): add comment reply and recent posts tools 2025-10-27 16:13:06 +08:00
Tim
016276dbc3 Merge pull request #1092 from nagisa77/codex/add-endpoints-for-post-and-comment-queries 2025-10-27 16:06:18 +08:00
Tim
bd2d6e7485 Add recent post and comment context APIs 2025-10-27 16:05:40 +08:00
Tim
df59a9fd4b Merge pull request #1091 from nagisa77/feature/nginx
fix: 修改配置
2025-10-27 14:28:18 +08:00
tim
2e70a3d273 fix: 修改配置 2025-10-27 14:27:17 +08:00
Tim
3dc6935d19 Merge pull request #1090 from nagisa77/feature/nginx
Feature/nginx
2025-10-26 14:28:05 +08:00
Tim
779bb2db78 Merge pull request #1089 from nagisa77/codex/update-.env.example-for-openisle_mcp_port
Configure MCP proxy routes
2025-10-26 14:26:35 +08:00
Tim
b3b0b194a3 Configure MCP port and nginx proxies 2025-10-26 14:25:09 +08:00
tim
e21b2f42d2 fix: 精简websocket配置 2025-10-26 14:17:38 +08:00
Tim
05a5acee7e Merge pull request #1088 from nagisa77/feature/mcp
Feature/mcp
2025-10-26 14:08:46 +08:00
Tim
755982098b Merge pull request #1087 from nagisa77/codex/add-mcp-service-to-deploy-scripts
Include MCP service in deployment scripts
2025-10-26 14:07:15 +08:00
Tim
af24263c0a Include MCP service in deployment scripts 2025-10-26 14:07:03 +08:00
tim
8fd268bd11 feat: add mcp 2025-10-25 23:33:51 +08:00
Tim
a24bd81942 Merge pull request #1080 from nagisa77/codex/modify-post-visibility-and-update-changelog
Log post visibility scope changes
2025-10-24 11:08:22 +08:00
Tim
8a008a090a Log post visibility changes 2025-10-24 10:41:10 +08:00
Tim
5dfb69e636 Merge pull request #1075 from smallclover/main
markdown 调试信息中的错误日志删除
2025-10-24 10:14:09 +08:00
Tim
499069573e fix: 弹窗修改为overlay 2025-10-24 10:11:56 +08:00
Tim
636912941a fix: commit 2025-10-23 17:56:45 +08:00
Tim
bdcc1488b9 fix: 修改可见范围 2025-10-23 17:54:03 +08:00
Tim
d33bd233af fix: 修改登录遮罩 2025-10-23 17:22:49 +08:00
Tim
efe4b97d83 Merge branch 'main' into main 2025-10-23 17:06:25 +08:00
Tim
8a256e167d Merge pull request #1031 from sivdead/feat/category_proposal
feat: 添加分类提案功能,包括提案表单和相关后端逻辑
2025-10-23 17:03:48 +08:00
Tim
9c5a49a47f fix: 完善提案通知流程,防止重复通知,提案成功自动插入分类 2025-10-23 15:29:55 +08:00
Tim
2271bbbd1d feat: 分类提案首页icon 2025-10-23 14:30:57 +08:00
Tim
d6470e04fc fix: 分类提案投票UI 2025-10-23 14:28:07 +08:00
Tim
4db35a4531 fix: 解决后台报错的问题 2025-10-23 13:43:39 +08:00
Tim
1906ffd8aa Update CONTRIBUTING.md to simplify instructions
Removed redundant build command from contributing guide.
2025-10-23 12:30:37 +08:00
Tim
426884385f fix: 更新巡航指南 2025-10-23 12:27:15 +08:00
Tim
8193c92c91 fix: 新增提案规则,新增自定义后端 2025-10-23 12:11:22 +08:00
Tim
90649b422d feat: 补充提案规则ui 2025-10-22 20:39:54 +08:00
Tim
67efb64ccc fix: 分类提案简化用户输入 2025-10-22 20:33:24 +08:00
Tim
23d8eafc08 fix: 删除替换为icon-park 2025-10-22 20:15:34 +08:00
Tim
d1cc16e31e Merge remote-tracking branch 'origin/main' into feat_category_proposal 2025-10-22 19:54:17 +08:00
Tim
0f1c45b155 Merge pull request #1078 from xuemian168/patch-1
Enhance security policy with detailed guidelines
2025-10-22 10:19:26 +08:00
XueMian (ICT.RUN)
8ed11df99c Enhance security policy with detailed guidelines
Expanded the security policy to include detailed reporting procedures, security considerations, and best practices for contributors.
2025-10-22 10:10:57 +10:00
smallclover
458b125834 1.追加文章可见范围功能
2.删除文章右侧滚动条
2025-10-18 22:32:22 +09:00
smallclover
971a3d36c6 修复:header文字不换行 2025-10-17 23:19:45 +09:00
smallclover
e5d66d73cb markdown 调试信息中的错误日志删除 2025-10-17 22:34:58 +09:00
Tim
a9608cc706 Merge pull request #1074 from nagisa77/feature/search_bar
Feature/search bar
2025-10-17 17:43:04 +08:00
Tim
232f40151b fix: 修改css冲突 2025-10-17 17:42:12 +08:00
Tim
3b3f99754d fix: 搜索focus 2025-10-17 17:33:34 +08:00
Tim
e14566ee66 fix: 搜索提示 2025-10-17 17:01:55 +08:00
Tim
892312c6d4 fix: 搜索框 2025-10-17 16:59:40 +08:00
Tim
dfb31771ff feat: searchbar集成到header 2025-10-17 16:54:03 +08:00
Tim
bf7df629cc Merge pull request #1073 from nagisa77/feature/ui_fix
fix: avatar 以及 auth 重构
2025-10-17 15:11:55 +08:00
Tim
f17b644a9b fix: avatar 以及 auth 重构 2025-10-17 15:10:43 +08:00
Tim
61f8fa4bb7 fix: 新增右间距 2025-10-17 12:19:40 +08:00
Tim
43929bcdc5 Merge pull request #1072 from nagisa77/feature/give_some_money
fix: 移动端ui适配
2025-10-17 12:02:03 +08:00
Tim
6aecb4f583 fix: 移动端ui适配 2025-10-17 12:01:32 +08:00
Tim
0d2e6a9505 Merge pull request #1065 from nagisa77/feature/give_some_money
打赏功能实现
2025-10-17 11:36:34 +08:00
Tim
b2d70b9bde Merge pull request #1069 from nagisa77/codex/add-reinitialize-command-to-contributing.md
docs: document docker compose volume reset workflow
2025-10-17 11:36:15 +08:00
Tim
d914579d64 fix: Donate历史 2025-10-17 11:35:29 +08:00
Tim
8643446d8b feat: 赞赏后台 2025-10-17 11:24:19 +08:00
Tim
2db958f8c9 fix: 赞赏ui 2025-10-17 10:52:05 +08:00
Tim
fa29d255c9 Merge pull request #1067 from smallclover/main
tieba表情函数抽成共通
2025-10-16 22:35:43 +08:00
smallclover
b3fa5e2bef 修复已读 2025-10-16 21:19:13 +09:00
smallclover
a7ef4380d8 问题修复
1.修复网页模式下,markdown代码过长
2.修复网页模实下,按钮文字换行
3.修复网页模式下,消息换行
2025-10-16 21:13:56 +09:00
Tim
39d954d98a fix: 继续做UI工作 2025-10-16 18:11:27 +08:00
tim
ce04570efb feat: 新增itemGroup 2025-10-16 09:58:57 +08:00
smallclover
215c7077d5 tieba表情函数抽成共通 2025-10-15 22:47:48 +09:00
tim
a68c925c68 fix: 集成一下父亲容器 2025-10-15 19:52:30 +08:00
tim
4f248e8a71 fix: 布局微调 2025-10-15 18:08:16 +08:00
Tim
277883f9d9 Merge pull request #1064 from nagisa77/codex/refactor-reactionsgroup-to-remove-slot-mechanism
Refactor ReactionsGroup layout responsibilities
2025-10-15 17:55:55 +08:00
Tim
e9e996f291 refactor: simplify reactions group usage 2025-10-15 17:47:45 +08:00
LuoQianhong
c9854e1840 Merge branch 'main' into feat/category_proposal 2025-09-29 09:26:22 +08:00
sivdead
3da5d24488 fix: 修复代码合并问题 2025-09-25 18:17:26 +08:00
sivdead
76962d6d1c feat: 添加分类提案功能,包括提案表单和相关后端逻辑 2025-09-25 17:47:46 +08:00
96 changed files with 4459 additions and 464 deletions

View File

@@ -2,6 +2,7 @@
SERVER_PORT=8080
FRONTEND_PORT=3000
WEBSOCKET_PORT=8082
OPENISLE_MCP_PORT=8085
MYSQL_PORT=3306
REDIS_PORT=6379
RABBITMQ_PORT=5672

28
.github/workflows/reply-bots.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Reply Bots
on:
schedule:
- cron: "*/30 * * * *"
workflow_dispatch:
jobs:
run-reply-bot:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install --no-save @openai/agents ts-node typescript
- name: Run reply bot
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
run: npx ts-node --esm bots/reply_bots.ts

View File

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

176
SECURITY.md Normal file
View 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!

View File

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

View File

@@ -1,11 +1,13 @@
package com.openisle.controller;
import com.openisle.dto.CommentContextDto;
import com.openisle.dto.CommentDto;
import com.openisle.dto.CommentRequest;
import com.openisle.dto.PostChangeLogDto;
import com.openisle.dto.TimelineItemDto;
import com.openisle.mapper.CommentMapper;
import com.openisle.mapper.PostChangeLogMapper;
import com.openisle.mapper.PostMapper;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.*;
@@ -40,6 +42,7 @@ public class CommentController {
private final PointService pointService;
private final PostChangeLogService changeLogService;
private final PostChangeLogMapper postChangeLogMapper;
private final PostMapper postMapper;
@Value("${app.captcha.enabled:false}")
private boolean captchaEnabled;
@@ -184,6 +187,37 @@ public class CommentController {
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}")
@Operation(summary = "Delete comment", description = "Delete a comment")
@ApiResponse(responseCode = "200", description = "Deleted")

View File

@@ -66,6 +66,7 @@ public class PostController {
req.getContent(),
req.getTagIds(),
req.getType(),
req.getPostVisibleScopeType(),
req.getPrizeDescription(),
req.getPrizeIcon(),
req.getPrizeCount(),
@@ -73,7 +74,9 @@ public class PostController {
req.getStartTime(),
req.getEndTime(),
req.getOptions(),
req.getMultiple()
req.getMultiple(),
req.getProposedName(),
req.getProposalDescription()
);
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
@@ -101,7 +104,8 @@ public class PostController {
req.getCategoryId(),
req.getTitle(),
req.getContent(),
req.getTagIds()
req.getTagIds(),
req.getPostVisibleScopeType()
);
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
}
@@ -220,6 +224,26 @@ public class PostController {
.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")
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
@ApiResponse(

View File

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

View File

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

View 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;
}

View File

@@ -0,0 +1,11 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationRequest {
private int amount;
}

View 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;
}

View File

@@ -1,6 +1,7 @@
package com.openisle.dto;
import com.openisle.model.PostChangeType;
import com.openisle.model.PostVisibleScopeType;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Getter;
@@ -29,4 +30,7 @@ public class PostChangeLogDto {
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
private PostVisibleScopeType oldVisibleScope;
private PostVisibleScopeType newVisibleScope;
private Integer amount;
}

View File

@@ -3,6 +3,8 @@ package com.openisle.dto;
import com.openisle.model.PostType;
import java.time.LocalDateTime;
import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data;
/**
@@ -19,6 +21,7 @@ public class PostRequest {
// optional for lottery posts
private PostType type;
private PostVisibleScopeType postVisibleScopeType;
private String prizeDescription;
private String prizeIcon;
private Integer prizeCount;
@@ -28,4 +31,8 @@ public class PostRequest {
// fields for poll posts
private List<String> options;
private Boolean multiple;
// fields for category proposal posts
private String proposedName;
private String proposalDescription;
}

View File

@@ -4,6 +4,8 @@ import com.openisle.model.PostStatus;
import com.openisle.model.PostType;
import java.time.LocalDateTime;
import java.util.List;
import com.openisle.model.PostVisibleScopeType;
import lombok.Data;
/**
@@ -34,4 +36,5 @@ public class PostSummaryDto {
private PollDto poll;
private boolean rssExcluded;
private boolean closed;
private PostVisibleScopeType visibleScope;
}

View File

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

View File

@@ -52,6 +52,11 @@ public class PostChangeLogMapper {
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
} else if (log instanceof PostVisibleScopeChangeLog v) {
dto.setOldVisibleScope(v.getOldVisibleScope());
dto.setNewVisibleScope(v.getNewVisibleScope());
} else if (log instanceof PostDonateChangeLog d) {
dto.setAmount(d.getAmount());
}
return dto;
}

View File

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

View File

@@ -0,0 +1,59 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.PrimaryKeyJoinColumn;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A specialized post type used for proposing new categories.
* It reuses poll mechanics (participants, votes, endTime) by extending PollPost.
*/
@Entity
@Table(
name = "category_proposal_posts",
indexes = { @Index(name = "idx_category_proposal_posts_status", columnList = "status") }
)
@Getter
@Setter
@NoArgsConstructor
@PrimaryKeyJoinColumn(name = "post_id")
public class CategoryProposalPost extends PollPost {
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private CategoryProposalStatus proposalStatus = CategoryProposalStatus.PENDING;
@Column(name = "proposed_name", nullable = false, unique = true)
private String proposedName;
@Column(name = "description")
private String description;
// Approval threshold as percentage (0-100), default 60
@Column(name = "approve_threshold", nullable = false)
private int approveThreshold = 60;
// Minimum number of participants required to meet quorum
@Column(name = "quorum", nullable = false)
private int quorum = 10;
// Optional voting start time (end time inherited from PollPost)
@Column(name = "start_at")
private LocalDateTime startAt;
// Snapshot of poll results at finalization (e.g., JSON)
@Column(name = "result_snapshot", columnDefinition = "TEXT")
private String resultSnapshot;
// Reason when proposal is rejected
@Column(name = "reject_reason")
private String rejectReason;
}

View File

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

View File

@@ -46,8 +46,14 @@ public enum NotificationType {
POLL_RESULT_OWNER,
/** A poll you participated in has concluded */
POLL_RESULT_PARTICIPANT,
/** Your category proposal has concluded */
CATEGORY_PROPOSAL_RESULT_OWNER,
/** A category proposal you participated in has concluded */
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** Someone donated to your post */
DONATION,
/** You were mentioned in a post or comment */
MENTION,
}

View File

@@ -13,4 +13,6 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
DONATE_SENT,
DONATE_RECEIVED,
}

View File

@@ -66,6 +66,10 @@ public class Post {
@Column(nullable = false)
private PostType type = PostType.NORMAL;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
@Column(nullable = false)
private boolean closed = false;

View File

@@ -8,6 +8,8 @@ public enum PostChangeType {
CLOSED,
PINNED,
FEATURED,
VISIBLE_SCOPE,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
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> {
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
Post post,
LocalDateTime createdAt
);
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
List<Comment> findByContentContainingIgnoreCase(String keyword);

View File

@@ -2,11 +2,14 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
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> {
List<PointHistory> findByUserOrderByIdDesc(User user);
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
List<PointHistory> findByComment(Comment comment);
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);
}

View File

@@ -19,6 +19,10 @@ public interface PostRepository extends JpaRepository<Post, Long> {
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
PostStatus status,
LocalDateTime createdAt
);
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
User author,
PostStatus status,

View File

@@ -266,6 +266,27 @@ public class CommentService {
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) {
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
User user = userRepository

View File

@@ -1,5 +1,7 @@
package com.openisle.service;
import com.openisle.dto.DonationDto;
import com.openisle.dto.DonationResponse;
import com.openisle.exception.FieldException;
import com.openisle.model.*;
import com.openisle.repository.*;
@@ -8,8 +10,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -20,6 +24,8 @@ public class PointService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
private final NotificationService notificationService;
private final PostChangeLogService postChangeLogService;
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
@@ -272,4 +278,95 @@ public class PointService {
User user = userRepository.findByUsername(userName).orElseThrow();
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;
}
}

View File

@@ -99,6 +99,21 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordVisibleScopeChange(
Post post,
User user,
PostVisibleScopeType oldVisibleScope,
PostVisibleScopeType newVisibleScope
) {
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
log.setPost(post);
log.setUser(user);
log.setType(PostChangeType.VISIBLE_SCOPE);
log.setOldVisibleScope(oldVisibleScope);
log.setNewVisibleScope(newVisibleScope);
logRepository.save(log);
}
public void recordVoteResult(Post post) {
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
log.setPost(post);
@@ -115,6 +130,15 @@ public class PostChangeLogService {
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) {
logRepository.deleteByPost(post);
}

View File

@@ -1,9 +1,10 @@
package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.exception.NotFoundException;
import com.openisle.exception.RateLimitException;
import com.openisle.mapper.PostMapper;
import com.openisle.model.*;
import com.openisle.repository.CategoryProposalPostRepository;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.LotteryPostRepository;
@@ -21,7 +22,6 @@ import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -32,7 +32,6 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.data.domain.PageRequest;
@@ -54,6 +53,7 @@ public class PostService {
private final TagRepository tagRepository;
private final LotteryPostRepository lotteryPostRepository;
private final PollPostRepository pollPostRepository;
private final CategoryProposalPostRepository categoryProposalPostRepository;
private final PollVoteRepository pollVoteRepository;
private PublishMode publishMode;
private final NotificationService notificationService;
@@ -71,11 +71,17 @@ public class PostService {
private final PointService pointService;
private final PostChangeLogService postChangeLogService;
private final PointHistoryRepository pointHistoryRepository;
private final CategoryService categoryService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
private final SearchIndexEventPublisher searchIndexEventPublisher;
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -89,6 +95,7 @@ public class PostService {
TagRepository tagRepository,
LotteryPostRepository lotteryPostRepository,
PollPostRepository pollPostRepository,
CategoryProposalPostRepository categoryProposalPostRepository,
PollVoteRepository pollVoteRepository,
NotificationService notificationService,
SubscriptionService subscriptionService,
@@ -107,7 +114,8 @@ public class PostService {
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate,
SearchIndexEventPublisher searchIndexEventPublisher
SearchIndexEventPublisher searchIndexEventPublisher,
CategoryService categoryService
) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -115,6 +123,7 @@ public class PostService {
this.tagRepository = tagRepository;
this.lotteryPostRepository = lotteryPostRepository;
this.pollPostRepository = pollPostRepository;
this.categoryProposalPostRepository = categoryProposalPostRepository;
this.pollVoteRepository = pollVoteRepository;
this.notificationService = notificationService;
this.subscriptionService = subscriptionService;
@@ -135,6 +144,7 @@ public class PostService {
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
this.categoryService = categoryService;
}
@EventListener(ApplicationReadyEvent.class)
@@ -160,6 +170,24 @@ public class PostService {
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
if (cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
}
}
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
now,
CategoryProposalStatus.PENDING
)) {
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
}
}
public PublishMode getPublishMode() {
@@ -225,6 +253,7 @@ public class PostService {
String content,
List<Long> tagIds,
PostType type,
PostVisibleScopeType postVisibleScopeType,
String prizeDescription,
String prizeIcon,
Integer prizeCount,
@@ -232,10 +261,12 @@ public class PostService {
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options,
Boolean multiple
Boolean multiple,
String proposedName,
String proposalDescription
) {
// 限制访问次数
boolean limitResult = postRateLimit(username);
boolean limitResult = isPostLimitReached(username);
if (!limitResult) {
throw new RateLimitException("Too many posts");
}
@@ -278,6 +309,25 @@ public class PostService {
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else if (actualType == PostType.PROPOSAL) {
CategoryProposalPost cp = new CategoryProposalPost();
if (proposedName == null || proposedName.isBlank()) {
throw new IllegalArgumentException("Proposed name required");
}
String normalizedName = proposedName.trim();
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
}
cp.setProposedName(normalizedName);
cp.setDescription(proposalDescription);
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
LocalDateTime now = LocalDateTime.now();
cp.setStartAt(now);
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
cp.setMultiple(false);
post = cp;
} else {
post = new Post();
}
@@ -288,8 +338,18 @@ public class PostService {
post.setCategory(category);
post.setTags(new HashSet<>(tags));
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
// 什么都没设置的情况下默认为ALL
if (Objects.isNull(postVisibleScopeType)) {
post.setVisibleScope(PostVisibleScopeType.ALL);
} else {
post.setVisibleScope(postVisibleScopeType);
}
if (post instanceof LotteryPost) {
post = lotteryPostRepository.save((LotteryPost) post);
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
post = categoryProposalPostRepository.save(categoryProposalPost);
} else if (post instanceof PollPost) {
post = pollPostRepository.save((PollPost) post);
} else {
@@ -344,6 +404,12 @@ public class PostService {
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(lp.getId(), future);
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
);
scheduledFinalizations.put(cp.getId(), future);
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
ScheduledFuture<?> future = taskScheduler.schedule(
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
@@ -354,24 +420,110 @@ public class PostService {
if (post.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(post);
}
markPostLimit(author.getUsername());
return post;
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
public void finalizeProposal(Long postId) {
scheduledFinalizations.remove(postId);
categoryProposalPostRepository
.findById(postId)
.ifPresent(cp -> {
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
return;
}
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
int approveVotes = 0;
if (cp.getVotes() != null) {
approveVotes = cp.getVotes().getOrDefault(0, 0);
}
boolean quorumMet = totalParticipants >= cp.getQuorum();
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
boolean approved = false;
String rejectReason = null;
if (quorumMet && thresholdMet) {
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
approved = true;
} else {
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
String reason;
if (!quorumMet && !thresholdMet) {
reason = "未达到法定人数且赞成率不足";
} else if (!quorumMet) {
reason = "未达到法定人数";
} else {
reason = "赞成率不足";
}
cp.setRejectReason(reason);
rejectReason = reason;
}
cp.setResultSnapshot(
"approveVotes=" +
approveVotes +
", totalParticipants=" +
totalParticipants +
", approvePercent=" +
approvePercent
);
categoryProposalPostRepository.save(cp);
if (approved) {
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
}
if (cp.getAuthor() != null) {
notificationService.createNotification(
cp.getAuthor(),
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
for (User participant : cp.getParticipants()) {
if (
cp.getAuthor() != null &&
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
) {
continue;
}
notificationService.createNotification(
participant,
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
cp,
null,
approved,
null,
null,
approved ? null : rejectReason
);
}
postChangeLogService.recordVoteResult(cp);
});
}
/**
* 限制发帖频率
* 检查用户是否达到发帖限制
* @param username
* @return
* @return true - 允许发帖false - 已达限制
*/
private boolean postRateLimit(String username) {
private boolean isPostLimitReached(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
String result = (String) redisTemplate.opsForValue().get(key);
//最近没有创建过文章
if (StringUtils.isEmpty(result)) {
// 限制频率为5分钟
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
return true;
}
return false;
return StringUtils.isEmpty(result);
}
/**
* 标记用户发帖触发limit计时
* @param username
*/
private void markPostLimit(String username) {
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
}
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@@ -450,6 +602,9 @@ public class PostService {
pollPostRepository
.findById(postId)
.ifPresent(pp -> {
if (pp instanceof CategoryProposalPost) {
return;
}
if (pp.isResultAnnounced()) {
return;
}
@@ -571,7 +726,7 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getStatus() != PostStatus.PUBLISHED) {
if (viewer == null) {
throw new com.openisle.exception.NotFoundException("Post not found");
throw new com.openisle.exception.NotFoundException("User not found");
}
User viewerUser = userRepository
.findByUsername(viewer)
@@ -615,6 +770,18 @@ public class PostService {
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) {
return listPostsByViews(null, null, page, pageSize);
}
@@ -1002,7 +1169,8 @@ public class PostService {
Long categoryId,
String title,
String content,
java.util.List<Long> tagIds
List<Long> tagIds,
PostVisibleScopeType postVisibleScopeType
) {
if (tagIds == null || tagIds.isEmpty()) {
throw new IllegalArgumentException("At least one tag required");
@@ -1034,6 +1202,8 @@ public class PostService {
post.setContent(content);
post.setCategory(category);
post.setTags(new java.util.HashSet<>(tags));
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
post.setVisibleScope(postVisibleScopeType);
Post updated = postRepository.save(post);
imageUploader.adjustReferences(oldContent, content);
notificationService.notifyMentions(content, user, updated, null);
@@ -1055,6 +1225,14 @@ public class PostService {
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
postChangeLogService.recordVisibleScopeChange(
updated,
user,
oldVisibleScope,
postVisibleScopeType
);
}
if (updated.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(updated);
}

View File

@@ -13,6 +13,7 @@ spring.jpa.hibernate.ddl-auto=update
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
spring.data.redis.database=${REDIS_DATABASE:0}
spring.data.redis.password=${REDIS_PASS: null}
# for jwt
app.jwt.secret=${JWT_SECRET:jwt_sec}

View File

@@ -0,0 +1,25 @@
-- Create table for category proposal posts (subclass of poll_posts)
CREATE TABLE IF NOT EXISTS category_proposal_posts (
post_id BIGINT NOT NULL,
status VARCHAR(50) NOT NULL,
proposed_name VARCHAR(255) NOT NULL,
proposed_slug VARCHAR(255) NOT NULL,
description VARCHAR(255),
approve_threshold INT NOT NULL DEFAULT 60,
quorum INT NOT NULL DEFAULT 10,
start_at DATETIME(6) NULL,
result_snapshot LONGTEXT NULL,
reject_reason VARCHAR(255),
PRIMARY KEY (post_id),
CONSTRAINT fk_category_proposal_posts_parent
FOREIGN KEY (post_id) REFERENCES poll_posts (post_id)
);
CREATE INDEX IF NOT EXISTS idx_category_proposal_posts_status
ON category_proposal_posts (status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_slug
ON category_proposal_posts (proposed_slug);

View File

@@ -0,0 +1 @@
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'

View File

@@ -0,0 +1,8 @@
ALTER TABLE category_proposal_posts
DROP INDEX idx_category_proposal_posts_slug;
ALTER TABLE category_proposal_posts
DROP COLUMN proposed_slug;
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
ON category_proposal_posts (proposed_name);

View File

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

View File

@@ -26,6 +26,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -52,6 +53,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -104,6 +106,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -130,6 +133,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -195,6 +199,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -221,6 +226,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -260,6 +266,11 @@ class PostServiceTest {
null,
null,
null,
null,
null,
null,
null,
null,
null
)
);
@@ -273,6 +284,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -299,6 +311,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,
@@ -367,6 +380,7 @@ class PostServiceTest {
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
@@ -393,6 +407,7 @@ class PostServiceTest {
tagRepo,
lotteryRepo,
pollPostRepo,
proposalRepo,
pollVoteRepo,
notifService,
subService,

View File

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

131
bots/reply_bots.ts Normal file
View File

@@ -0,0 +1,131 @@
// reply_bot.ts
import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
console.log("✅ Reply bot starting...");
const allowedMcpTools = [
"search",
"reply_to_post",
"reply_to_comment",
"recent_posts",
"get_post",
"list_unread_messages",
"mark_notifications_read",
];
console.log("🛠️ Configured Hosted MCP tools:", allowedMcpTools.join(", "));
// ---- MCP 工具Hosted MCP ----
// 关键点requireApproval 设为 "never",避免卡在人工批准。
const mcp = hostedMcpTool({
serverLabel: "openisle_mcp",
serverUrl: "https://www.open-isle.com/mcp",
allowedTools: allowedMcpTools,
requireApproval: "never",
});
type WorkflowInput = { input_as_text: string };
// 从环境变量读取你的站点鉴权令牌(可选)
const OPENISLE_TOKEN = process.env.OPENISLE_TOKEN ?? "";
console.log(
OPENISLE_TOKEN
? "🔑 OPENISLE_TOKEN detected in environment."
: "🔓 OPENISLE_TOKEN not set; agent will request it if required."
);
// ---- 定义 Agent ----
const openisleBot = new Agent({
name: "OpenIsle Bot",
instructions: [
"You are a helpful and cute assistant for https://www.open-isle.com. Please use plenty of kawaii kaomoji (颜表情), such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., in your replies to create a friendly, adorable vibe.",
"Finish tasks end-to-end before replying. If multiple MCP tools are needed, call them sequentially until the task is truly done.",
"When presenting the result, reply in Chinese with a concise, cute summary filled with kaomoji and include any important URLs or IDs.",
OPENISLE_TOKEN
? `If tools require auth, use this token exactly where the tool schema expects it: ${OPENISLE_TOKEN}`
: "If a tool requires auth, ask me to provide OPENISLE_TOKEN via env.",
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
].join("\n"),
tools: [mcp],
model: "gpt-4o",
modelSettings: {
temperature: 0.7,
topP: 1,
maxTokens: 2048,
toolChoice: "auto",
store: true,
},
});
// ---- 入口函数:跑到拿到 finalOutput 为止,然后输出并退出 ----
export const runWorkflow = async (workflow: WorkflowInput) => {
// 强烈建议在外部shell设置 OPENAI_API_KEY
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing OPENAI_API_KEY");
}
const runner = new Runner({
workflowName: "OpenIsle Bot",
traceMetadata: {
__trace_source__: "agent-builder",
workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8",
},
// 如需完全禁用上报可加tracingDisabled: true
});
return await withTrace("OpenIsle Bot run", async () => {
const preview = workflow.input_as_text.trim();
console.log(
"📝 Received workflow input (preview):",
preview.length > 200 ? `${preview.slice(0, 200)}` : preview
);
// Runner.run 会自动循环执行LLM → 工具 → 直至 finalOutput
console.log("🚦 Starting agent run with maxTurns=16...");
const result = await runner.run(openisleBot, workflow.input_as_text, {
maxTurns: 16, // 允许更复杂任务多轮调用 MCP
// stream: true // 如需边跑边看事件可打开,然后消费流事件
});
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
if (!result.finalOutput) {
// 若没产出最终结果,通常是启用了人工批准/工具失败/达到 maxTurns
throw new Error("Agent result is undefined (no final output).");
}
const openisleBotResult = { output_text: String(result.finalOutput) };
console.log(
"🤖 Agent result (length=%d):\n%s",
openisleBotResult.output_text.length,
openisleBotResult.output_text
);
return openisleBotResult;
});
};
// ---- CLI 运行(示范)----
if (require.main === module) {
(async () => {
try {
const query = `
【AUTO】无需确认自动处理所有未读的提及与评论
1调用 list_unread_messages
2依次处理每条“提及/评论”:如需上下文则使用 get_post 获取,生成简明中文回复;如有 commentId 则用 reply_to_comment否则用 reply_to_post
3跳过关注和系统事件
4保证幂等性如该贴最后一条是你自己发的回复则跳过
5调用 mark_notifications_read传入本次已处理的通知 ID 清理已读;
6最多只处理最新10条结束时仅输出简要摘要包含URL或ID
`;
console.log("🔍 Running workflow...");
await runWorkflow({ input_as_text: query });
process.exit(0);
} catch (err: any) {
console.error("❌ Agent failed:", err?.stack || err);
process.exit(1);
}
})();
}

View File

@@ -40,12 +40,12 @@ echo "👉 Build images ..."
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service
frontend_service mcp
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

@@ -36,16 +36,15 @@ echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images (staging)..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service
frontend_service mcp
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
mysql redis rabbitmq websocket-service springboot frontend_service mcp
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps

View File

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

21
docker/mcp.Dockerfile Normal file
View 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"]

View File

@@ -41,10 +41,13 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
import ConfirmDialog from '~/components/ConfirmDialog.vue'
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
import { checkToken } from '~/utils/auth'
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
await checkToken()
const showNewPostIcon = computed(() => useRoute().path === '/')
const hideMenu = computed(() => {

View File

@@ -3,7 +3,7 @@
--primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5);
--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);
--header-height: 60px;
--header-background-color: white;
@@ -54,6 +54,7 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--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);
--header-text-color: white;
--app-menu-background-color: #333;
@@ -179,7 +180,7 @@ body {
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
white-space: nowrap; /* 禁止数字换行 */
white-space: nowrap; /* 禁止数字换行 */
font-variant-numeric: tabular-nums; /* 数字等宽 */
/* width: 2em; */
font-size: 13px;
@@ -205,7 +206,6 @@ body {
border-radius: 4px;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
white-space: pre; /* 禁止自动换行 */
}
.copy-code-btn {
@@ -344,7 +344,7 @@ body {
.info-content-text pre {
line-height: 1.5;
}
/*处理iframe视频标签*/
.info-content-text iframe {
width: 100%;
@@ -370,7 +370,10 @@ body {
.d2h-code-line {
padding-left: 10px !important;
}
/* 手机端不换行 */
.info-content-text code {
white-space: pre; /* 禁止自动换行 */
}
/* .d2h-diff-table {
font-size: 6px !important;
}

View File

@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
import { useAttrs } from 'vue'
const props = defineProps({
src: { type: String, required: true },
src: { type: String, default: '' },
alt: { type: String, default: '' },
})
@@ -39,9 +39,6 @@ const placeholder = computed(() => {
function onLoad() {
loaded.value = true
}
function onError() {
loaded.value = true
}
</script>
<style scoped>

View 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>

View File

@@ -1,22 +1,20 @@
<template>
<NuxtLink
:to="resolvedLink"
<div
class="base-user-avatar"
:class="wrapperClass"
:style="wrapperStyle"
v-bind="wrapperAttrs"
@click="handleClick"
>
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
</NuxtLink>
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, watch } from 'vue'
import { useAttrs } from 'vue'
import BaseImage from './BaseImage.vue'
const DEFAULT_AVATAR = '/default-avatar.svg'
const props = defineProps({
userId: {
type: [String, Number],
@@ -50,15 +48,6 @@ const props = defineProps({
const attrs = useAttrs()
const currentSrc = ref(props.src || DEFAULT_AVATAR)
watch(
() => props.src,
(value) => {
currentSrc.value = value || DEFAULT_AVATAR
},
)
const resolvedLink = computed(() => {
if (props.to) return props.to
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
@@ -70,10 +59,16 @@ const resolvedLink = computed(() => {
const altText = computed(() => props.alt || '用户头像')
const sizeStyle = computed(() => {
if (!props.width && props.width !== 0) return null
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
if (!value) return null
return { width: value, height: value }
var style = {}
if (props.width > 0) {
style.width = `${props.width}px`
}
if (props.height > 0) {
style.height = `${props.height}px`
}
return style
})
const wrapperStyle = computed(() => {
@@ -88,10 +83,9 @@ const wrapperAttrs = computed(() => {
return rest
})
function onError() {
if (currentSrc.value !== DEFAULT_AVATAR) {
currentSrc.value = DEFAULT_AVATAR
}
const handleClick = () => {
if (props.disableLink) return
navigateTo(resolvedLink.value)
}
</script>
@@ -109,7 +103,7 @@ function onError() {
}
.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);
}

View File

@@ -53,14 +53,29 @@
@click="handleContentClick"
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
<ReactionsGroup
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 />
</div>
<div class="make-reaction-item copy-link" @click="copyCommentLink">
<div class="reaction-action copy-link" @click="copyCommentLink">
<link-icon />
</div>
</ReactionsGroup>
</div>
</div>
<div class="comment-editor-wrapper" ref="editorWrapper">
<CommentEditor
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
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 replyCount = computed(() => countReplies(props.comment.reply || []))
const isCommentFromPostAuthor = computed(() => {
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
</script>
<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 {
cursor: pointer;
color: var(--primary-color);
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
color: var(--primary-color);
}
.comment-reaction:hover {
background-color: lightgray;
}
.comment-highlight {
animation: highlight 2s;
}
@@ -424,6 +488,16 @@ const handleContentClick = (e) => {
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 {
font-size: 12px;
margin-left: 1px;

View 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>

View File

@@ -168,9 +168,19 @@ export default {
const mobileMenuRef = ref(null)
const isMobile = useIsMobile()
const openMenu = () => {
if (!open.value) {
open.value = true
}
}
const toggle = () => {
open.value = !open.value
if (!open.value) emit('close')
if (open.value) {
open.value = false
emit('close')
} else {
open.value = true
}
}
const close = () => {
@@ -275,7 +285,7 @@ export default {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
expose({ toggle, close, reload, scrollToBottom })
expose({ toggle, close, reload, scrollToBottom, openMenu })
return {
open,
@@ -308,7 +318,6 @@ export default {
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
@@ -331,6 +340,7 @@ export default {
z-index: 10000;
max-height: 300px;
min-width: 350px;
margin-top: 4px;
overflow-y: auto;
}

View File

@@ -26,6 +26,11 @@
<ClientOnly>
<div class="header-content-right">
<SearchDropdown
ref="searchDropdown"
v-if="!isMobile || showSearch"
@close="closeSearch"
/>
<!-- 搜索 -->
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
<div class="header-icon-item" @click="search">
@@ -78,7 +83,9 @@
<div class="header-icon-item" @click="goToMessages">
<message-emoji class="header-icon" />
<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>
</div>
</ToolTip>
@@ -89,10 +96,9 @@
<BaseUserAvatar
class="avatar-img"
:user-id="authState.userId"
:src="avatar"
alt="avatar"
:width="32"
:src="authState.avatar"
:disable-link="true"
:width="32"
/>
<down />
</div>
@@ -105,7 +111,6 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
</template>
@@ -117,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.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 { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
@@ -139,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const isCopying = ref(false)
const onlineCount = ref(0)
// 心跳检测
@@ -208,7 +211,7 @@ const copyInviteLink = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
isCopying.value = false // 🔥 修复:未登录时立即复原状态
isCopying.value = false // 🔥 修复:未登录时立即复原状态
return
}
try {
@@ -252,17 +255,7 @@ const copyRssLink = async () => {
}
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
let id = authState.username || authState.id
if (id) {
navigateTo(`/users/${id}`, { replace: true })
}
@@ -306,14 +299,6 @@ const iconClass = computed(() => {
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
fetchUnreadCount()
@@ -323,17 +308,8 @@ onMounted(async () => {
}
}
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
)
// 新增的在线人数逻辑
sendPing()
fetchCount()
@@ -482,7 +458,6 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text:hover {
opacity: 0.8;
text-decoration: underline;
@@ -543,7 +518,10 @@ onMounted(async () => {
color: var(--primary-color);
cursor: pointer;
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 {
@@ -565,6 +543,7 @@ onMounted(async () => {
.header-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
/* 在线人数的数字文字样式(无背景) */
@@ -572,15 +551,14 @@ onMounted(async () => {
position: absolute;
top: -4px;
right: -6px;
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
line-height: 1;
padding: 0; /* 去掉内边距 */
padding: 0; /* 去掉内边距 */
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);

View File

@@ -3,15 +3,30 @@
<div class="login-overlay-blur"></div>
<div class="login-overlay-content">
<user-icon class="login-overlay-icon" />
<div class="login-overlay-text">请先登录点击跳转到登录页面</div>
<div class="login-overlay-button" @click="goLogin">登录</div>
<div class="login-overlay-text">{{ props.text }}</div>
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
text: {
type: String,
default: '请先登录,点击跳转到登录页面',
},
buttonText: {
type: String,
default: '登录',
},
buttonLink: {
type: String,
default: '/login',
},
})
const goLogin = () => {
navigateTo('/login', { replace: true })
navigateTo(props.buttonLink, { replace: true })
}
</script>

View File

@@ -45,6 +45,7 @@ export default {
font-size: 12px;
cursor: pointer;
margin-left: 10px;
white-space: nowrap;
}
.mark-read-button:hover {
@@ -53,6 +54,7 @@ export default {
.has-read-button {
font-size: 12px;
white-space: nowrap;
}
@media (max-width: 768px) {

View File

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

View File

@@ -36,12 +36,19 @@
<template v-if="log.newFeatured">将文章设为精选</template>
<template v-else>取消精选文章</template>
</span>
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
变更了文章可见范围, {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
{{ formatVisibleScope(log.newVisibleScope) }}
</span>
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
>系统已计算投票结果</span
>
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div
@@ -66,6 +73,17 @@ const props = defineProps({
title: String,
})
const VISIBLE_SCOPE_LABELS = {
ALL: '全部可见',
ONLY_ME: '仅自己可见',
ONLY_REGISTER: '仅注册用户可见',
}
const formatVisibleScope = (scope) => {
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
return VISIBLE_SCOPE_LABELS[scope] ?? scope
}
const diffHtml = computed(() => {
// Track theme changes
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'

View File

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

View File

@@ -34,6 +34,7 @@ export default {
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
]
}

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -18,9 +18,11 @@
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
<ToolTip content="发表心情" placement="bottom">
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
</ToolTip>
</template>
<template v-else-if="displayedReactions.length">
<div
@@ -35,21 +37,11 @@
</template>
</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
v-if="panelVisible"
class="reactions-panel"
ref="reactionsPanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
@@ -69,7 +61,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
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 likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
@@ -152,9 +142,11 @@ const displayedReactions = computed(() => {
.map((type) => ({ type }))
})
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
const panelTypes = computed(() => sortedReactionTypes.value)
const panelVisible = ref(false)
const reactionsPanelRef = ref(null)
const panelInlineStyle = ref({})
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
@@ -170,6 +162,33 @@ const cancelHide = () => {
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 token = getToken()
if (!token) {
@@ -245,6 +264,15 @@ const toggleReaction = async (type) => {
onMounted(async () => {
await initialize()
window.addEventListener('resize', updatePanelInlineStyle)
})
defineExpose({
toggleReaction,
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
</script>
@@ -253,11 +281,7 @@ onMounted(async () => {
position: relative;
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.reactions-viewer {
@@ -295,40 +319,15 @@ onMounted(async () => {
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 {
font-size: 16px;
font-weight: bold;
margin-right: 15px;
}
.reactions-panel {
position: absolute;
bottom: 50px;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
@@ -361,7 +360,6 @@ onMounted(async () => {
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;

View File

@@ -17,7 +17,8 @@
<input
class="text-input"
v-model="keyword"
placeholder="Search"
placeholder="键盘点击「/」以触发搜索"
ref="searchInput"
@input="setSearch(keyword)"
/>
</div>
@@ -48,7 +49,7 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
@@ -61,8 +62,48 @@ const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const searchInput = ref(null)
const isMobile = useIsMobile()
const isEditableElement = (el) => {
if (!el) return false
if (el.isContentEditable) return true
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
return true
}
const role = el.getAttribute ? el.getAttribute('role') : null
return role === 'textbox'
}
const focusSearchInput = () => {
if (!searchInput.value) return
dropdown.value?.openMenu?.()
if (typeof searchInput.value.focus === 'function') {
try {
searchInput.value.focus({ preventScroll: true })
} catch (e) {
searchInput.value.focus()
}
}
}
const handleGlobalSlash = (event) => {
if (event.defaultPrevented) return
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
if (isEditableElement(document.activeElement)) return
event.preventDefault()
focusSearchInput()
}
onMounted(() => {
window.addEventListener('keydown', handleGlobalSlash)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleGlobalSlash)
})
const toggle = () => {
dropdown.value.toggle()
}
@@ -144,8 +185,7 @@ defineExpose({
<style scoped>
.search-dropdown {
margin-top: 20px;
width: 500px;
width: 300px;
}
.search-mobile-trigger {
@@ -154,7 +194,7 @@ defineExpose({
}
.search-input {
padding: 10px;
padding: 2px 10px;
display: flex;
align-items: center;
width: 100%;
@@ -202,7 +242,7 @@ defineExpose({
}
.result-body {
line-height: 1;
line-height: 1;
display: flex;
flex-direction: column;
}
@@ -216,4 +256,14 @@ defineExpose({
font-size: 12px;
color: #666;
}
.search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10000;
}
</style>

View File

@@ -1,10 +1,5 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
<div class="topic-container">
<div class="topic-item-container">
<div
@@ -72,11 +67,13 @@
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
<lock class="preview-close-icon" v-if="article.isRestricted" />
</NuxtLink>
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="sanitizeDescription(article.description)"></div>
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
@@ -116,7 +113,7 @@
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<InfiniteLoadMore
v-if="articles.length > 0"
:key="ioKey"
@@ -143,6 +140,7 @@ import { useIsMobile } from '~/utils/screen'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import TimeManager from '~/utils/time'
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
@@ -298,6 +296,7 @@ const {
comments: p.commentCount,
views: p.views,
rssExcluded: p.rssExcluded || false,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
@@ -339,6 +338,7 @@ const fetchNextPage = async () => {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
rssExcluded: p.rssExcluded || false,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
@@ -378,28 +378,6 @@ onBeforeUnmount(() => {
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
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]) => {
selectedCategoryGlobal.value = newCategory
@@ -564,14 +542,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.header-item.views {
width: 5%;
justify-content: flex-end;
text-align: right;
text-align: right;
}
.article-time,
.header-item.activity {
width: 10%;
justify-content: flex-end;
text-align: left;
text-align: left;
}
.article-item-title {
@@ -593,6 +571,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.pinned-icon,
.lottery-icon,
.featured-icon,
.proposal-icon,
.poll-icon {
margin-right: 4px;
color: var(--primary-color);

View File

@@ -40,7 +40,7 @@
<script setup>
import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { setToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
import { registerPush } from '~/utils/push'
@@ -61,7 +61,6 @@ const submitLogin = async () => {
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
await navigateTo('/', { replace: true })

View File

@@ -61,14 +61,31 @@
@click="handleContentClick"
></div>
</div>
<ReactionsGroup
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
</ReactionsGroup>
<div class="message-reaction-row">
<ReactionsGroup
:ref="(el) => setMessageReactionRef(item.id, el)"
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
/>
<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>
</BaseTimeline>
<div class="empty-container">
@@ -180,6 +197,32 @@ function setReply(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 */
function scrollToBottomSmooth() {
const el = messagesListEl.value
@@ -710,6 +753,55 @@ function goBack() {
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 {
display: flex;
flex-direction: row;
@@ -723,14 +815,8 @@ function goBack() {
}
.reply-btn {
cursor: pointer;
padding: 4px;
opacity: 0.6;
font-size: 12px;
}
.reply-btn:hover {
opacity: 1;
color: var(--primary-color);
}
.active-reply {

View File

@@ -84,7 +84,7 @@
>
<div class="conversation-avatar">
<BaseImage
:src="ch.avatar || '/default-avatar.svg'"
:src="ch.avatar"
:alt="ch.name"
class="avatar-img"
@error="handleAvatarError"
@@ -194,7 +194,7 @@ function formatTime(timeString) {
// 头像加载失败处理
function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
event.target.src = null
}
async function fetchChannels() {

View File

@@ -75,7 +75,9 @@
@click="markRead(item.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>
</span>
回复了
@@ -85,7 +87,9 @@
@click="markRead(item.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>
</span>
</NotificationContainer>
@@ -115,7 +119,9 @@
@click="markRead(item.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>
</span>
</NotificationContainer>
@@ -162,7 +168,9 @@
@click="markRead(item.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>
</span>
进行了表态
@@ -251,6 +259,38 @@
已出结果
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
<NotificationContainer :item="item" :markRead="markRead">
你的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
<NotificationContainer :item="item" :markRead="markRead">
你参与的分类提案
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
<span v-if="item.approved">已通过</span>
<span v-else>
未通过<span v-if="item.content">原因{{ item.content }}</span>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -267,7 +307,7 @@
@click="markRead(item.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>
</NotificationContainer>
</template>
@@ -287,7 +327,9 @@
@click="markRead(item.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
@@ -295,7 +337,7 @@
@click="markRead(item.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>
</NotificationContainer>
</template>
@@ -323,7 +365,7 @@
@click="markRead(item.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>
</NotificationContainer>
</template>
@@ -342,7 +384,7 @@
@click="markRead(item.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>
</NotificationContainer>
</template>
@@ -542,6 +584,27 @@
被收录为精选
</NotificationContainer>
</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'">
<NotificationContainer :item="item" :markRead="markRead">
管理员
@@ -556,7 +619,7 @@
</template>
删除了您的帖子
<span class="notif-content-text">
{{ stripMarkdownLength(item.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
</span>
</NotificationContainer>
</template>
@@ -586,7 +649,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,
@@ -754,6 +817,10 @@ const formatType = (t) => {
return '发布的投票结果已公布'
case 'POLL_RESULT_PARTICIPANT':
return '参与的投票结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
return '分类提案结果已公布'
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
return '参与的分类提案结果已公布'
default:
return t
}

View File

@@ -11,6 +11,7 @@
<CategorySelect v-model="selectedCategory" />
<TagSelect v-model="selectedTags" creatable />
<PostTypeSelect v-model="postType" />
<PostVisibleScopeSelect v-model="postVisibleScope"/>
</div>
<div class="post-options-right">
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
@@ -37,6 +38,7 @@
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
<PollForm v-if="postType === 'POLL'" :data="poll" />
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
</div>
</div>
</template>
@@ -50,8 +52,10 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import LotteryForm from '~/components/LotteryForm.vue'
import PollForm from '~/components/PollForm.vue'
import ProposalForm from '~/components/ProposalForm.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -60,6 +64,7 @@ const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const postType = ref('NORMAL')
const postVisibleScope = ref('ALL')
const lottery = reactive({
prizeIcon: '',
prizeIconFile: null,
@@ -76,6 +81,10 @@ const poll = reactive({
endTime: null,
multiple: false,
})
const proposal = reactive({
proposedName: '',
proposalDescription: '',
})
const startTime = ref(null)
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
@@ -94,6 +103,7 @@ const loadDraft = async () => {
content.value = data.content || ''
selectedCategory.value = data.categoryId || ''
selectedTags.value = data.tagIds || []
postVisibleScope.value = data.visiblescope
toast.success('草稿已加载')
}
@@ -109,6 +119,7 @@ const clearPost = async () => {
content.value = ''
selectedCategory.value = ''
selectedTags.value = []
postVisibleScope.value = 'ALL'
postType.value = 'NORMAL'
lottery.prizeIcon = ''
lottery.prizeIconFile = null
@@ -123,6 +134,8 @@ const clearPost = async () => {
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
proposal.proposedName = ''
proposal.proposalDescription = ''
// 删除草稿
const token = getToken()
@@ -160,6 +173,7 @@ const saveDraft = async () => {
content: content.value,
categoryId: selectedCategory.value || null,
tagIds,
postVisibleScopeType:postVisibleScope.value
}),
})
if (res.ok) {
@@ -283,6 +297,12 @@ const submitPost = async () => {
return
}
}
if (postType.value === 'PROPOSAL') {
if (!proposal.proposedName.trim()) {
toast.error('请填写拟议分类名称')
return
}
}
try {
const token = getToken()
await ensureTags(token)
@@ -303,36 +323,46 @@ const submitPost = async () => {
}
prizeIconUrl = uploadData.data.url
}
const toUtcString = (value) => {
if (!value) return undefined
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
}
const payload = {
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
postVisibleScopeType: postVisibleScope.value,
}
if (postType.value === 'LOTTERY') {
payload.prizeIcon = prizeIconUrl
payload.prizeName = lottery.prizeName
payload.prizeCount = lottery.prizeCount
payload.prizeDescription = lottery.prizeDescription
payload.pointCost = lottery.pointCost
payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined
payload.endTime = toUtcString(lottery.endTime)
} else if (postType.value === 'POLL') {
payload.options = poll.options
payload.multiple = poll.multiple
payload.endTime = toUtcString(poll.endTime)
} else if (postType.value === 'PROPOSAL') {
payload.proposedName = proposal.proposedName
payload.proposalDescription = proposal.proposalDescription
}
const res = await fetch(`${API_BASE_URL}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
title: title.value,
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
type: postType.value,
prizeIcon: postType.value === 'LOTTERY' ? prizeIconUrl : undefined,
prizeName: postType.value === 'LOTTERY' ? lottery.prizeName : undefined,
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
options: postType.value === 'POLL' ? poll.options : undefined,
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
endTime:
postType.value === 'LOTTERY'
? new Date(new Date(lottery.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: postType.value === 'POLL'
? new Date(new Date(poll.endTime).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
: undefined,
}),
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok) {
if (data.reward && data.reward > 0) {

View File

@@ -184,6 +184,27 @@
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</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>
<paper-money-two /> 你目前的积分是 {{ item.balance }}
</div>
@@ -248,6 +269,8 @@ const iconMap = {
FEATURE: 'star',
LOTTERY_JOIN: 'medal-one',
LOTTERY_REWARD: 'fireworks',
DONATE_SENT: 'paper-money-two',
DONATE_RECEIVED: 'paper-money-two',
POST_LIKE_CANCELLED: 'clear-icon',
COMMENT_LIKE_CANCELLED: 'clear-icon',
}

View File

@@ -10,6 +10,7 @@
<div class="post-options-left">
<CategorySelect v-model="selectedCategory" />
<TagSelect v-model="selectedTags" creatable />
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
</div>
<div class="post-options-right">
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
@@ -44,6 +45,7 @@ import TagSelect from '~/components/TagSelect.vue'
import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue'
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -51,6 +53,7 @@ const title = ref('')
const content = ref('')
const selectedCategory = ref('')
const selectedTags = ref([])
const selectedVisibleScope = ref('ALL')
const isWaitingPosting = ref(false)
const isAiLoading = ref(false)
const isLogin = computed(() => authState.loggedIn)
@@ -70,6 +73,7 @@ const loadPost = async () => {
content.value = data.content || ''
selectedCategory.value = data.category.id || ''
selectedTags.value = (data.tags || []).map((t) => t.id)
selectedVisibleScope.value = data.visibleScope
}
} catch (e) {
toast.error('加载失败')
@@ -180,6 +184,7 @@ const submitPost = async () => {
content: content.value,
categoryId: selectedCategory.value,
tagIds: selectedTags.value,
postVisibleScopeType:selectedVisibleScope.value
}),
})
const data = await res.json()

View File

@@ -1,5 +1,22 @@
<template>
<div class="post-page-container">
<div v-if="isRestricted" class="restricted-content">
<template v-if="visibleScope === 'ONLY_ME'">
<LoginOverlay
text="这是一篇私密文章,仅作者本人及管理员可见"
button-text="返回首页"
button-link="/"
/>
</template>
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
<LoginOverlay
text="这是一篇仅登录用户可见的文章,请先登录"
button-text="登录"
button-link="/login"
/>
</template>
</div>
<div v-else class="post-page-container">
<div v-if="isWaitingFetchingPost" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
@@ -16,7 +33,9 @@
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
<div v-if="closed" class="article-closed-button">已关闭</div>
<div v-if="closed" class="article-gray-button">已关闭</div>
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
<div
v-if="!closed && loggedIn && !isAuthor && !subscribed"
class="article-subscribe-button"
@@ -92,11 +111,29 @@
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
<div class="make-reaction-item copy-link" @click="copyPostLink">
<div class="article-option-container">
<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 />
</div>
</ReactionsGroup>
</div>
</div>
</div>
</div>
@@ -147,25 +184,6 @@
</div>
</div>
<div class="post-page-scroller-container">
<div class="scroller">
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
<div class="scroller-middle">
<input
type="range"
class="scroller-range"
:max="totalPosts"
:min="1"
v-model.number="currentIndex"
@input="onSliderInput"
/>
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
</div>
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
</div>
</div>
<vue-easy-lightbox
:visible="lightboxVisible"
:index="lightboxIndex"
@@ -196,6 +214,7 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DonateGroup from '~/components/DonateGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
@@ -209,6 +228,7 @@ import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
import { useConfirm } from '~/composables/useConfirm'
import { Lock } from '@icon-park/vue-next'
const { confirm } = useConfirm()
const config = useRuntimeConfig()
@@ -222,7 +242,26 @@ const author = ref('')
const postContent = ref('')
const category = ref('')
const tags = ref([])
const visibleScope = ref('ALL') // 可见范围
const isRestricted = computed(() => {
return (
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
)
})
const postReactions = ref([])
const postReactionsGroupRef = ref(null)
const postLikeCount = computed(
() => 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 changeLogs = ref([])
const status = ref('PUBLISHED')
@@ -366,9 +405,9 @@ const changeLogIcon = (l) => {
return 'unlock'
}
} else if (l.type === 'PINNED') {
if(l.newPinnedAt){
if (l.newPinnedAt) {
return 'pin'
}else{
} else {
return 'clear-icon'
}
} else if (l.type === 'FEATURED') {
@@ -377,10 +416,20 @@ const changeLogIcon = (l) => {
} else {
return 'dislike'
}
} else if (l.type === 'VISIBLE_SCOPE') {
if (l.newVisibleScope === 'ONLY_ME') {
return 'lock-one'
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
return 'peoples-two'
} else {
return 'communication'
}
} else if (l.type === 'VOTE_RESULT') {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else if (l.type === 'DONATE') {
return 'financing'
} else {
return 'info'
}
@@ -405,6 +454,9 @@ const mapChangeLog = (l) => ({
newCategory: l.newCategory,
oldTags: l.oldTags,
newTags: l.newTags,
oldVisibleScope: l.oldVisibleScope,
newVisibleScope: l.newVisibleScope,
amount: l.amount,
icon: changeLogIcon(l),
})
@@ -463,15 +515,27 @@ const onCommentDeleted = (id) => {
fetchTimeline()
}
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
const {
data: postData,
pending: pendingPost,
error: postError,
refresh: refreshPost,
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
server: true,
lazy: false,
})
} = await useAsyncData(
`post-${postId}`,
async () => {
try {
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
} catch (err) {}
},
{
server: false,
lazy: false,
},
)
// 用 pendingPost 驱动现有 UI替代 isWaitingFetchingPost 手控)
const isWaitingFetchingPost = computed(() => pendingPost.value)
@@ -485,6 +549,7 @@ watchEffect(() => {
title.value = data.title
category.value = data.category
tags.value = data.tags || []
visibleScope.value = data.visibleScope || 'ALL'
postReactions.value = data.reactions || []
subscribed.value = !!data.subscribed
status.value = data.status
@@ -901,7 +966,7 @@ onMounted(async () => {
<style>
.post-page-container {
background-color: var(--background-color);
display: flex;
display: block;
flex-direction: row;
}
@@ -914,9 +979,10 @@ onMounted(async () => {
}
.post-page-main-container {
position: relative;
scrollbar-width: none;
padding: 20px;
width: calc(85% - 40px);
width: calc(100% - 40px);
}
.info-content-text p code {
@@ -968,6 +1034,35 @@ onMounted(async () => {
opacity: 0.5;
}
.skeleton {
background-color: #eee;
border-radius: 8px;
overflow: hidden;
position: relative;
height: 20px;
margin-top: 5px;
}
.skeleton::before {
content: '';
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
transform: translateX(-100%);
animation: skeleton-shimmer 1.5s infinite linear;
z-index: 1;
border-radius: 8px;
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(100%);
}
}
.user-avatar-container {
display: flex;
flex-direction: row;
@@ -1072,7 +1167,7 @@ onMounted(async () => {
white-space: nowrap;
}
.article-closed-button,
.article-gray-button,
.article-subscribe-button-text,
.article-featured-button,
.article-unsubscribe-button-text {
@@ -1125,7 +1220,7 @@ onMounted(async () => {
font-size: 14px;
}
.article-closed-button {
.article-gray-button {
background-color: var(--background-color);
color: gray;
border: 1px solid gray;
@@ -1245,35 +1340,61 @@ onMounted(async () => {
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.reactions-viewer {
.article-option-container {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.reactions-viewer-item-container {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
}
.reactions-viewer-item {
font-size: 16px;
}
.make-reaction-container {
.article-footer-actions {
display: flex;
flex-direction: row;
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;
}
@@ -1281,6 +1402,76 @@ onMounted(async () => {
position: relative;
}
/* ======== 权限锁定状态 ======== */
.is-blurred {
filter: blur(10px);
pointer-events: none;
user-select: none;
transition: filter 0.3s ease;
}
/* 遮罩层 */
.restricted-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
backdrop-filter: blur(12px);
background: rgba(0, 0, 0, 0.45);
animation: fadeIn 0.3s ease forwards;
}
/* 中央提示框 */
.restricted-content {
background: #ffff;
color: var(--primary-color);
text-align: center;
}
.restricted-icon {
font-size: 60px;
opacity: 0.8;
margin-bottom: 15px;
}
.restricted-button {
display: inline-block;
margin-top: 20px;
padding: 10px 18px;
background: var(--primary-color);
color: white;
border-radius: 8px;
text-decoration: none;
transition: background 0.2s ease;
}
.restricted-button:hover {
background: var(--primary-color-hover);
}
.restricted-actions {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 768px) {
.post-page-main-container {
width: calc(100% - 20px);
@@ -1322,6 +1513,7 @@ onMounted(async () => {
.article-footer-container {
margin-top: 0;
margin-bottom: 0px;
}
.loading-container {

View File

@@ -70,7 +70,7 @@
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { toast } from '~/main'
import { loadCurrentUser, setToken } from '~/utils/auth'
import { setToken } from '~/utils/auth'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
const route = useRoute()
@@ -172,7 +172,6 @@ const verifyCode = async () => {
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
toast.success('注册成功')
setToken(data.token)
loadCurrentUser()
navigateTo('/', { replace: true })
} else if (data.reason_code === 'VERIFIED') {
if (registerMode.value === 'WHITELIST') {

View File

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

View File

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

View File

@@ -1,33 +1,28 @@
import { reactive } from 'vue'
const TOKEN_KEY = 'token'
const USER_ID_KEY = 'userId'
const USERNAME_KEY = 'username'
const ROLE_KEY = 'role'
export const authState = reactive({
loggedIn: false,
userId: null,
username: null,
role: null,
avatar: null,
})
if (import.meta.client) {
authState.loggedIn =
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() {
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
}
export function setToken(token) {
export async function setToken(token) {
if (import.meta.client) {
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) {
authState.userId = id
authState.username = username
if (arguments[0] && arguments[0].role) {
authState.role = arguments[0].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)
authState.userId = user.id
authState.username = user.username
authState.avatar = user.avatar
authState.role = user.role
}
}
export function clearUserInfo() {
if (import.meta.client) {
localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(USERNAME_KEY)
localStorage.removeItem(ROLE_KEY)
authState.userId = null
authState.username = null
authState.avatar = null
authState.role = null
}
}
@@ -82,9 +71,11 @@ export async function fetchCurrentUser() {
export async function loadCurrentUser() {
const user = await fetchCurrentUser()
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() {
@@ -100,10 +91,12 @@ export async function checkToken() {
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
headers: { Authorization: `Bearer ${token}` },
})
authState.loggedIn = res.ok
return res.ok
if (res.ok) {
await setToken(token)
} else {
clearToken()
}
} catch (e) {
authState.loggedIn = false
return false
clearToken()
}
}

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function discordAuthorize(inviteToken = '') {
@@ -47,7 +47,6 @@ export async function discordExchange(code, inviteToken = '', reason = '') {
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function githubAuthorize(inviteToken = '') {
@@ -45,7 +45,6 @@ export async function githubExchange(code, inviteToken = '', reason = '') {
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export async function googleGetIdToken() {
@@ -79,7 +79,6 @@ export async function googleAuthWithToken(
if (res.ok && data && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
if (typeof redirect_success === 'function') redirect_success()

View File

@@ -265,3 +265,24 @@ export function stripMarkdownLength(text, 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
}

View File

@@ -28,9 +28,12 @@ const iconMap = {
POLL_VOTE: 'ChartHistogram',
POLL_RESULT_OWNER: 'RankingList',
POLL_RESULT_PARTICIPANT: 'ChartLine',
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
MENTION: 'HashtagKey',
POST_DELETED: 'ClearIcon',
POST_FEATURED: 'Star',
DONATION: 'PaperMoneyTwo',
}
export async function fetchUnreadCount() {
@@ -253,7 +256,9 @@ function createFetchNotifications() {
} else if (
n.type === 'POLL_VOTE' ||
n.type === 'POLL_RESULT_OWNER' ||
n.type === 'POLL_RESULT_PARTICIPANT'
n.type === 'POLL_RESULT_PARTICIPANT' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
) {
arr.push({
...n,
@@ -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') {
arr.push({
...n,

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
export function telegramAuthorize(inviteToken = '') {
@@ -34,7 +34,6 @@ export async function telegramExchange(authData, inviteToken = '', reason = '')
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }

View File

@@ -1,5 +1,5 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { setToken } from './auth'
import { registerPush } from './push'
function generateCodeVerifier() {
@@ -99,7 +99,6 @@ export async function twitterExchange(code, state, reason) {
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush()
return { success: true, needReason: false }

41
mcp/README.md Normal file
View 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
View 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

View File

@@ -0,0 +1,6 @@
"""OpenIsle MCP server package."""
from .config import Settings, get_settings
__all__ = ["Settings", "get_settings"]

View 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()

View File

@@ -0,0 +1,372 @@
"""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, field_validator
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")
@field_validator("replies", "reactions", mode="before")
@classmethod
def _ensure_comment_lists(cls, value: Any) -> list[Any]:
"""Convert ``None`` payloads to empty lists for comment collections."""
if value is None:
return []
return value
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")
@field_validator("tags", "reactions", "participants", mode="before")
@classmethod
def _ensure_post_lists(cls, value: Any) -> list[Any]:
"""Normalize ``None`` values returned by the backend to empty lists."""
if value is None:
return []
return value
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")
@field_validator("comments", mode="before")
@classmethod
def _ensure_comments_list(cls, value: Any) -> list[Any]:
"""Treat ``None`` comments payloads as empty lists."""
if value is None:
return []
return value
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.",
)
class NotificationCleanupResult(BaseModel):
"""Structured response returned after marking notifications as read."""
processed_ids: list[int] = Field(
default_factory=list,
description="Identifiers that were marked as read in the backend.",
)
total_marked: int = Field(
description="Total number of notifications successfully marked as read.",
)

View File

@@ -0,0 +1,315 @@
"""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 mark_notifications_read(
self,
ids: list[int],
*,
token: str | None = None,
) -> None:
"""Mark the provided notifications as read for the authenticated user."""
if not ids:
raise ValueError(
"At least one notification identifier must be provided to mark as read."
)
sanitized_ids: list[int] = []
for value in ids:
if isinstance(value, bool):
raise ValueError("Notification identifiers must be integers, not booleans.")
try:
converted = int(value)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
raise ValueError(
"Notification identifiers must be integers."
) from exc
if converted <= 0:
raise ValueError(
"Notification identifiers must be positive integers."
)
sanitized_ids.append(converted)
client = self._get_client()
resolved_token = self._require_token(token)
logger.debug(
"Marking %d notifications as read: ids=%s",
len(sanitized_ids),
sanitized_ids,
)
response = await client.post(
"/api/notifications/read",
json={"ids": sanitized_ids},
headers=self._build_headers(token=resolved_token, include_json=True),
)
response.raise_for_status()
logger.info(
"Successfully marked %d notifications as read.",
len(sanitized_ids),
)
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

View File

@@ -0,0 +1,639 @@
"""Entry point for running the OpenIsle MCP server."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
from mcp.server.fastmcp import Context, FastMCP
from pydantic import ValidationError
from pydantic import Field as PydanticField
from .config import get_settings
from .schemas import (
CommentCreateResult,
CommentData,
CommentReplyResult,
NotificationData,
NotificationCleanupResult,
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
),
)
@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 an "
"authentication token, 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 an authentication token.",
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,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = 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_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
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,
sanitized_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 an authentication token.",
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,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = 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_token = token.strip() if isinstance(token, str) else None
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
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,
sanitized_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."),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description="Optional JWT bearer token to view the post as an authenticated user.",
),
] = None,
ctx: Context | None = None,
) -> PostDetail:
"""Fetch post details from the backend and validate the response."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info("Fetching post details for post_id=%s", post_id)
raw_post = await search_client.get_post(post_id, sanitized_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,
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> UnreadNotificationsResponse:
"""Retrieve unread notifications and return structured data."""
sanitized_token = token.strip() if isinstance(token, str) else None
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=sanitized_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,
)
@app.tool(
name="mark_notifications_read",
description="Mark specific notification messages as read to remove them from the unread list.",
structured_output=True,
)
async def mark_notifications_read(
ids: Annotated[
list[int],
PydanticField(
min_length=1,
description="Notification identifiers that should be marked as read.",
),
],
token: Annotated[
str | None,
PydanticField(
default=None,
description=(
"Optional JWT bearer token. When omitted the configured access token is used."
),
),
] = None,
ctx: Context | None = None,
) -> NotificationCleanupResult:
"""Mark the supplied notifications as read and report the processed identifiers."""
sanitized_token = token.strip() if isinstance(token, str) else None
if sanitized_token == "":
sanitized_token = None
try:
logger.info(
"Marking %d notifications as read", # pragma: no branch - logging
len(ids),
)
await search_client.mark_notifications_read(ids, token=sanitized_token)
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
message = (
"OpenIsle backend returned HTTP "
f"{exc.response.status_code} while marking notifications as read."
)
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
processed_ids: list[int] = []
for value in ids:
if isinstance(value, bool):
raise ValueError("Notification identifiers must be integers, not booleans.")
converted = int(value)
if converted <= 0:
raise ValueError("Notification identifiers must be positive integers.")
processed_ids.append(converted)
if ctx is not None:
await ctx.info(
f"Marked {len(processed_ids)} notifications as read.",
)
logger.debug(
"Successfully marked notifications as read: ids=%s",
processed_ids,
)
return NotificationCleanupResult(
processed_ids=processed_ids,
total_marked=len(processed_ids),
)
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()

View File

@@ -100,10 +100,28 @@ server {
# auth_basic_user_file /etc/nginx/.htpasswd;
}
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8084/;
proxy_pass http://127.0.0.1:8084/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
location /mcp {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;

View File

@@ -8,11 +8,8 @@ server {
listen 443 ssl;
server_name staging.open-isle.com www.staging.open-isle.com;
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
@@ -40,59 +37,13 @@ server {
add_header X-Upstream $upstream_addr always;
}
# 1) 原生 WebSocket
location ^~ /api/ws {
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
proxy_http_version 1.1;
# 升级所需
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
}
# 2) SockJS包含 /info、/iframe.html、/.../websocket 等)
location ^~ /api/sockjs {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin
# proxy_hide_header X-Frame-Options;
# add_header X-Frame-Options "SAMEORIGIN" always;
}
# ---------- API ----------
location /api/ {
proxy_pass http://127.0.0.1:8081/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
@@ -109,7 +60,6 @@ server {
proxy_cache_bypass 1;
}
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8083/;
proxy_http_version 1.1;
@@ -130,4 +80,24 @@ server {
add_header Cache-Control "no-store" always;
}
}
location /mcp {
proxy_pass http://127.0.0.1:8086;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
}