mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-10 00:51:00 +08:00
Compare commits
1 Commits
codex/fix-
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e58a5741 |
@@ -2,7 +2,6 @@
|
|||||||
SERVER_PORT=8080
|
SERVER_PORT=8080
|
||||||
FRONTEND_PORT=3000
|
FRONTEND_PORT=3000
|
||||||
WEBSOCKET_PORT=8082
|
WEBSOCKET_PORT=8082
|
||||||
OPENISLE_MCP_PORT=8085
|
|
||||||
MYSQL_PORT=3306
|
MYSQL_PORT=3306
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
RABBITMQ_PORT=5672
|
RABBITMQ_PORT=5672
|
||||||
|
|||||||
29
.github/workflows/coffee-bot.yml
vendored
29
.github/workflows/coffee-bot.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
name: Coffee Bot
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 1 * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-coffee-bot:
|
|
||||||
environment: Bots
|
|
||||||
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 tsx typescript
|
|
||||||
|
|
||||||
- name: Run coffee bot
|
|
||||||
env:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
|
||||||
run: npx tsx bots/instance/coffee_bot.ts
|
|
||||||
29
.github/workflows/reply-bots.yml
vendored
29
.github/workflows/reply-bots.yml
vendored
@@ -1,29 +0,0 @@
|
|||||||
name: Reply Bots
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "*/30 * * * *"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
run-reply-bot:
|
|
||||||
environment: Bots
|
|
||||||
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 tsx typescript
|
|
||||||
|
|
||||||
- name: Run reply bot
|
|
||||||
env:
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
OPENISLE_TOKEN: ${{ secrets.OPENISLE_TOKEN }}
|
|
||||||
run: npx tsx bots/instance/reply_bot.ts
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
- [前置工作](#前置工作)
|
- [前置工作](#前置工作)
|
||||||
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
- [前端极速调试(Docker 全量环境)](#前端极速调试docker-全量环境)
|
||||||
- [dev 与 dev_local_backend 巡航指南](#dev-dev_local_backend-guide)
|
|
||||||
- [启动后端服务](#启动后端服务)
|
- [启动后端服务](#启动后端服务)
|
||||||
- [本地 IDEA](#本地-idea)
|
- [本地 IDEA](#本地-idea)
|
||||||
- [配置环境变量](#配置环境变量)
|
- [配置环境变量](#配置环境变量)
|
||||||
@@ -40,6 +39,13 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||||
2. 启动 Dev Profile:
|
2. 启动 Dev Profile:
|
||||||
|
```shell
|
||||||
|
docker compose \
|
||||||
|
-f docker/docker-compose.yaml \
|
||||||
|
--env-file .env \
|
||||||
|
--profile dev build
|
||||||
|
```
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker/docker-compose.yaml \
|
-f docker/docker-compose.yaml \
|
||||||
@@ -47,8 +53,8 @@ cd OpenIsle
|
|||||||
--profile dev up -d
|
--profile dev up -d
|
||||||
```
|
```
|
||||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||||
修改前端代码,页面会热更新。
|
|
||||||
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA,采用IDEA编译运行也可以哦。
|
修改代码后,可以强制重新创建所有容器,执行:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
@@ -67,49 +73,8 @@ cd OpenIsle
|
|||||||
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 down
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
|
|
||||||
```shell
|
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
|
|
||||||
```
|
|
||||||
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
|
|
||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||||
|
|
||||||
<a id="dev-dev_local_backend-guide"></a>
|
|
||||||
|
|
||||||
### 🧭 dev 与 dev_local_backend 巡航指南
|
|
||||||
|
|
||||||
在需要本地 IDE 启动后端、而容器只提供 MySQL、Redis、RabbitMQ、OpenSearch 等依赖时,可切换到 `dev_local_backend` Profile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose \
|
|
||||||
-f docker/docker-compose.yaml \
|
|
||||||
--env-file .env \
|
|
||||||
--profile dev_local_backend up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> 该 Profile 不会启动 Docker 内的 Spring Boot 服务,`frontend_dev_local_backend` 会通过 `host.docker.internal` 访问你本机正在运行的后端。非常适合用 IDEA/VS Code 调试 Java 服务的场景!
|
|
||||||
|
|
||||||
| 想要的体验 | 推荐 Profile | 会启动的关键容器 | 备注 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 🚀 一键启动前后端 | `dev` | `springboot`、`frontend_dev`、`mysql`… | 纯容器内跑全链路,省心省力 |
|
|
||||||
| 🛠️ IDE 启动后端 + 容器托管依赖 | `dev_local_backend` | `frontend_dev_local_backend`、`mysql`、`redis`… | 记得本地后端监听 `8080`/`8082` 等端口 |
|
|
||||||
|
|
||||||
切换 Profile 时,请先停掉当前组合再启动另一组,避免端口占用或容器命名冲突:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
|
||||||
# 或者
|
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev_local_backend down
|
|
||||||
```
|
|
||||||
|
|
||||||
常见小贴士:
|
|
||||||
|
|
||||||
- 🧹 需要彻底清理依赖时,别忘了追加 `-v` 清除持久化数据卷。
|
|
||||||
- 🪄 仅切换 Profile 时通常无需重新 `build`,除非你更新了镜像依赖。
|
|
||||||
- 🧪 如需确认前端容器访问的是本机后端,可在 IDE 控制台查看请求日志或执行 `curl http://localhost:8080/actuator/health` 进行自检。
|
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|
||||||
启动后端服务有多种方式,选择一种即可。
|
启动后端服务有多种方式,选择一种即可。
|
||||||
@@ -139,17 +104,6 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
|
||||||
> 如果你通过 `dev_local_backend` Profile 启动了数据库/缓存等依赖,却让后端由 IDEA 在宿主机运行,请务必将 `open-isle.env`(或 IDEA 的环境变量面板)中的主机名改成 `localhost`:
|
|
||||||
>
|
|
||||||
> ```ini
|
|
||||||
> MYSQL_HOST=localhost
|
|
||||||
> REDIS_HOST=localhost
|
|
||||||
> RABBITMQ_HOST=localhost
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 对应的容器端口均已映射到宿主机,无需额外配置。若仍保留默认的 `mysql`、`redis`、`rabbitmq`,IDEA 将尝试解析容器网络内的别名而导致连接失败。
|
|
||||||
|
|
||||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
176
SECURITY.md
176
SECURITY.md
@@ -1,176 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
| ------- | ------------------ |
|
|
||||||
| 0.0.x | :white_check_mark: |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
|
||||||
|
|
||||||
### How to Report a Security Vulnerability
|
|
||||||
|
|
||||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
|
||||||
|
|
||||||
Instead, please report them via one of the following methods:
|
|
||||||
|
|
||||||
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
|
|
||||||
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
|
|
||||||
|
|
||||||
### What to Include in Your Report
|
|
||||||
|
|
||||||
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
|
|
||||||
|
|
||||||
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
|
|
||||||
- Full paths of source file(s) related to the manifestation of the issue
|
|
||||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
|
||||||
- Any special configuration required to reproduce the issue
|
|
||||||
- Step-by-step instructions to reproduce the issue
|
|
||||||
- Proof-of-concept or exploit code (if possible)
|
|
||||||
- Impact of the issue, including how an attacker might exploit it
|
|
||||||
|
|
||||||
### Response Timeline
|
|
||||||
|
|
||||||
- **Initial Response**: We will acknowledge your report within 48 hours
|
|
||||||
- **Status Updates**: We will provide status updates at least every 5 business days
|
|
||||||
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
|
|
||||||
|
|
||||||
### What to Expect
|
|
||||||
|
|
||||||
After you submit a report:
|
|
||||||
|
|
||||||
1. We will confirm receipt of your vulnerability report and may ask for additional information
|
|
||||||
2. We will investigate the issue and determine its impact and severity
|
|
||||||
3. We will work on a fix and coordinate disclosure timing with you
|
|
||||||
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
|
|
||||||
|
|
||||||
## Security Considerations for Deployment
|
|
||||||
|
|
||||||
### Authentication & Authorization
|
|
||||||
|
|
||||||
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
|
|
||||||
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
|
|
||||||
- **Session Management**: Configure appropriate session timeout values
|
|
||||||
|
|
||||||
### Database Security
|
|
||||||
|
|
||||||
- Use strong database passwords
|
|
||||||
- Never expose database ports publicly
|
|
||||||
- Use database connection encryption when available
|
|
||||||
- Regularly backup your database
|
|
||||||
|
|
||||||
### API Security
|
|
||||||
|
|
||||||
- Enable rate limiting to prevent abuse
|
|
||||||
- Validate all user inputs on both client and server side
|
|
||||||
- Use HTTPS in production environments
|
|
||||||
- Configure CORS properly to restrict origins
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
The following sensitive environment variables should be kept secure:
|
|
||||||
|
|
||||||
- `JWT_SECRET` - JWT signing key
|
|
||||||
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
|
|
||||||
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
|
|
||||||
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
|
|
||||||
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
|
|
||||||
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
|
|
||||||
- Database connection strings and credentials
|
|
||||||
- Cloud storage credentials (Tencent COS)
|
|
||||||
|
|
||||||
**Never commit these values to version control or expose them in logs.**
|
|
||||||
|
|
||||||
### File Upload Security
|
|
||||||
|
|
||||||
- Validate file types and sizes
|
|
||||||
- Scan uploaded files for malware
|
|
||||||
- Store uploaded files outside the web root
|
|
||||||
- Use cloud storage with proper access controls
|
|
||||||
|
|
||||||
### Password Security
|
|
||||||
|
|
||||||
- Configure password strength requirements via environment variables
|
|
||||||
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
|
|
||||||
- Implement account lockout after failed login attempts
|
|
||||||
|
|
||||||
### Web Push Notifications
|
|
||||||
|
|
||||||
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
|
|
||||||
- Only send notifications to users who have explicitly opted in
|
|
||||||
- Validate notification payloads
|
|
||||||
|
|
||||||
### Dependency Management
|
|
||||||
|
|
||||||
- Regularly update dependencies to patch known vulnerabilities
|
|
||||||
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
|
|
||||||
- Monitor GitHub security advisories for this project
|
|
||||||
|
|
||||||
### Production Deployment Checklist
|
|
||||||
|
|
||||||
- [ ] Use HTTPS/TLS for all connections
|
|
||||||
- [ ] Set strong, unique secrets for all environment variables
|
|
||||||
- [ ] Enable CSRF protection
|
|
||||||
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
|
|
||||||
- [ ] Disable debug mode and verbose error messages
|
|
||||||
- [ ] Set up proper logging and monitoring
|
|
||||||
- [ ] Implement rate limiting and DDoS protection
|
|
||||||
- [ ] Regular security updates and patches
|
|
||||||
- [ ] Database backups and disaster recovery plan
|
|
||||||
- [ ] Restrict admin access to trusted IPs when possible
|
|
||||||
|
|
||||||
## Known Security Features
|
|
||||||
|
|
||||||
OpenIsle includes the following security features:
|
|
||||||
|
|
||||||
- JWT-based authentication with configurable expiration
|
|
||||||
- OAuth 2.0 integration with major providers
|
|
||||||
- Password strength validation
|
|
||||||
- Protection codes for sensitive operations
|
|
||||||
- Input validation and sanitization
|
|
||||||
- SQL injection prevention through ORM (JPA/Hibernate)
|
|
||||||
- XSS protection in Vue.js templates
|
|
||||||
- CSRF protection (Spring Security)
|
|
||||||
|
|
||||||
## Security Best Practices for Contributors
|
|
||||||
|
|
||||||
- Never commit credentials, API keys, or secrets
|
|
||||||
- Follow secure coding practices (OWASP Top 10)
|
|
||||||
- Validate and sanitize all user inputs
|
|
||||||
- Use parameterized queries for database operations
|
|
||||||
- Implement proper error handling without exposing sensitive information
|
|
||||||
- Write security tests for new features
|
|
||||||
- Review code for security issues before submitting PRs
|
|
||||||
|
|
||||||
## Disclosure Policy
|
|
||||||
|
|
||||||
When we receive a security bug report, we will:
|
|
||||||
|
|
||||||
1. Confirm the problem and determine affected versions
|
|
||||||
2. Audit code to find any similar problems
|
|
||||||
3. Prepare fixes for all supported versions
|
|
||||||
4. Release patches as soon as possible
|
|
||||||
|
|
||||||
We appreciate your help in keeping OpenIsle and its users safe!
|
|
||||||
|
|
||||||
## Attribution
|
|
||||||
|
|
||||||
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
|
|
||||||
|
|
||||||
- Security advisory
|
|
||||||
- Release notes
|
|
||||||
- A security hall of fame (if established)
|
|
||||||
|
|
||||||
If you prefer to remain anonymous, we will respect your wishes.
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
|
|
||||||
For any security-related questions or concerns, please reach out through the channels mentioned above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Thank you for helping keep OpenIsle secure!
|
|
||||||
@@ -19,7 +19,6 @@ JWT_EXPIRATION=2592000000
|
|||||||
# === Redis ===
|
# === Redis ===
|
||||||
REDIS_HOST=<Redis 地址>
|
REDIS_HOST=<Redis 地址>
|
||||||
REDIS_PORT=<Redis 端口>
|
REDIS_PORT=<Redis 端口>
|
||||||
REDIS_PASS=<Redis 密码>
|
|
||||||
|
|
||||||
# === Resend ===
|
# === Resend ===
|
||||||
RESEND_API_KEY=<你的resend-api-key>
|
RESEND_API_KEY=<你的resend-api-key>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package com.openisle.controller;
|
package com.openisle.controller;
|
||||||
|
|
||||||
import com.openisle.dto.CommentContextDto;
|
|
||||||
import com.openisle.dto.CommentDto;
|
import com.openisle.dto.CommentDto;
|
||||||
import com.openisle.dto.CommentRequest;
|
import com.openisle.dto.CommentRequest;
|
||||||
import com.openisle.dto.PostChangeLogDto;
|
import com.openisle.dto.PostChangeLogDto;
|
||||||
import com.openisle.dto.TimelineItemDto;
|
import com.openisle.dto.TimelineItemDto;
|
||||||
import com.openisle.mapper.CommentMapper;
|
import com.openisle.mapper.CommentMapper;
|
||||||
import com.openisle.mapper.PostChangeLogMapper;
|
import com.openisle.mapper.PostChangeLogMapper;
|
||||||
import com.openisle.mapper.PostMapper;
|
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.service.*;
|
import com.openisle.service.*;
|
||||||
@@ -42,7 +40,6 @@ public class CommentController {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService changeLogService;
|
private final PostChangeLogService changeLogService;
|
||||||
private final PostChangeLogMapper postChangeLogMapper;
|
private final PostChangeLogMapper postChangeLogMapper;
|
||||||
private final PostMapper postMapper;
|
|
||||||
|
|
||||||
@Value("${app.captcha.enabled:false}")
|
@Value("${app.captcha.enabled:false}")
|
||||||
private boolean captchaEnabled;
|
private boolean captchaEnabled;
|
||||||
@@ -187,37 +184,6 @@ public class CommentController {
|
|||||||
return itemDtoList;
|
return itemDtoList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/comments/{commentId}/context")
|
|
||||||
@Operation(
|
|
||||||
summary = "Comment context",
|
|
||||||
description = "Get a comment along with its previous comments and related post"
|
|
||||||
)
|
|
||||||
@ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "Comment context",
|
|
||||||
content = @Content(schema = @Schema(implementation = CommentContextDto.class))
|
|
||||||
)
|
|
||||||
public ResponseEntity<CommentContextDto> getCommentContext(@PathVariable Long commentId) {
|
|
||||||
log.debug("getCommentContext called for comment {}", commentId);
|
|
||||||
Comment comment = commentService.getComment(commentId);
|
|
||||||
CommentContextDto dto = new CommentContextDto();
|
|
||||||
dto.setPost(postMapper.toSummaryDto(comment.getPost()));
|
|
||||||
dto.setTargetComment(commentMapper.toDtoWithReplies(comment));
|
|
||||||
dto.setPreviousComments(
|
|
||||||
commentService
|
|
||||||
.getCommentsBefore(comment)
|
|
||||||
.stream()
|
|
||||||
.map(commentMapper::toDtoWithReplies)
|
|
||||||
.collect(Collectors.toList())
|
|
||||||
);
|
|
||||||
log.debug(
|
|
||||||
"getCommentContext returning {} previous comments for comment {}",
|
|
||||||
dto.getPreviousComments().size(),
|
|
||||||
commentId
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/comments/{id}")
|
@DeleteMapping("/comments/{id}")
|
||||||
@Operation(summary = "Delete comment", description = "Delete a comment")
|
@Operation(summary = "Delete comment", description = "Delete a comment")
|
||||||
@ApiResponse(responseCode = "200", description = "Deleted")
|
@ApiResponse(responseCode = "200", description = "Deleted")
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ public class PostController {
|
|||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds(),
|
req.getTagIds(),
|
||||||
req.getType(),
|
req.getType(),
|
||||||
req.getPostVisibleScopeType(),
|
|
||||||
req.getPrizeDescription(),
|
req.getPrizeDescription(),
|
||||||
req.getPrizeIcon(),
|
req.getPrizeIcon(),
|
||||||
req.getPrizeCount(),
|
req.getPrizeCount(),
|
||||||
@@ -74,9 +73,7 @@ public class PostController {
|
|||||||
req.getStartTime(),
|
req.getStartTime(),
|
||||||
req.getEndTime(),
|
req.getEndTime(),
|
||||||
req.getOptions(),
|
req.getOptions(),
|
||||||
req.getMultiple(),
|
req.getMultiple()
|
||||||
req.getProposedName(),
|
|
||||||
req.getProposalDescription()
|
|
||||||
);
|
);
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
@@ -104,8 +101,7 @@ public class PostController {
|
|||||||
req.getCategoryId(),
|
req.getCategoryId(),
|
||||||
req.getTitle(),
|
req.getTitle(),
|
||||||
req.getContent(),
|
req.getContent(),
|
||||||
req.getTagIds(),
|
req.getTagIds()
|
||||||
req.getPostVisibleScopeType()
|
|
||||||
);
|
);
|
||||||
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
return ResponseEntity.ok(postMapper.toDetailDto(post, auth.getName()));
|
||||||
}
|
}
|
||||||
@@ -224,26 +220,6 @@ public class PostController {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/recent")
|
|
||||||
@Operation(
|
|
||||||
summary = "Recent posts",
|
|
||||||
description = "List posts created within the specified number of minutes"
|
|
||||||
)
|
|
||||||
@ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "Recent posts",
|
|
||||||
content = @Content(
|
|
||||||
array = @ArraySchema(schema = @Schema(implementation = PostSummaryDto.class))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
public List<PostSummaryDto> recentPosts(@RequestParam("minutes") int minutes) {
|
|
||||||
return postService
|
|
||||||
.listRecentPosts(minutes)
|
|
||||||
.stream()
|
|
||||||
.map(postMapper::toSummaryDto)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/ranking")
|
@GetMapping("/ranking")
|
||||||
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
@Operation(summary = "Ranking posts", description = "List posts by view rankings")
|
||||||
@ApiResponse(
|
@ApiResponse(
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.openisle.dto;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class DonationRequest {
|
|
||||||
|
|
||||||
private int amount;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.openisle.dto;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class DonationResponse {
|
|
||||||
|
|
||||||
private int totalAmount;
|
|
||||||
private List<DonationDto> donations = new ArrayList<>();
|
|
||||||
private Integer balance;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.openisle.dto;
|
package com.openisle.dto;
|
||||||
|
|
||||||
import com.openisle.model.PostChangeType;
|
import com.openisle.model.PostChangeType;
|
||||||
import com.openisle.model.PostVisibleScopeType;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@@ -30,7 +29,4 @@ public class PostChangeLogDto {
|
|||||||
private LocalDateTime newPinnedAt;
|
private LocalDateTime newPinnedAt;
|
||||||
private Boolean oldFeatured;
|
private Boolean oldFeatured;
|
||||||
private Boolean newFeatured;
|
private Boolean newFeatured;
|
||||||
private PostVisibleScopeType oldVisibleScope;
|
|
||||||
private PostVisibleScopeType newVisibleScope;
|
|
||||||
private Integer amount;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package com.openisle.dto;
|
|||||||
import com.openisle.model.PostType;
|
import com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.openisle.model.PostVisibleScopeType;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +19,6 @@ public class PostRequest {
|
|||||||
|
|
||||||
// optional for lottery posts
|
// optional for lottery posts
|
||||||
private PostType type;
|
private PostType type;
|
||||||
private PostVisibleScopeType postVisibleScopeType;
|
|
||||||
private String prizeDescription;
|
private String prizeDescription;
|
||||||
private String prizeIcon;
|
private String prizeIcon;
|
||||||
private Integer prizeCount;
|
private Integer prizeCount;
|
||||||
@@ -31,8 +28,4 @@ public class PostRequest {
|
|||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
private Boolean multiple;
|
private Boolean multiple;
|
||||||
|
|
||||||
// fields for category proposal posts
|
|
||||||
private String proposedName;
|
|
||||||
private String proposalDescription;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import com.openisle.model.PostStatus;
|
|||||||
import com.openisle.model.PostType;
|
import com.openisle.model.PostType;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import com.openisle.model.PostVisibleScopeType;
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,5 +34,4 @@ public class PostSummaryDto {
|
|||||||
private PollDto poll;
|
private PollDto poll;
|
||||||
private boolean rssExcluded;
|
private boolean rssExcluded;
|
||||||
private boolean closed;
|
private boolean closed;
|
||||||
private PostVisibleScopeType visibleScope;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package com.openisle.dto;
|
|
||||||
|
|
||||||
import com.openisle.model.CategoryProposalStatus;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.EqualsAndHashCode;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@EqualsAndHashCode(callSuper = true)
|
|
||||||
public class ProposalDto extends PollDto {
|
|
||||||
|
|
||||||
private CategoryProposalStatus proposalStatus;
|
|
||||||
private String proposedName;
|
|
||||||
private String description;
|
|
||||||
private int approveThreshold;
|
|
||||||
private int quorum;
|
|
||||||
private LocalDateTime startAt;
|
|
||||||
private String resultSnapshot;
|
|
||||||
private String rejectReason;
|
|
||||||
}
|
|
||||||
@@ -52,11 +52,6 @@ public class PostChangeLogMapper {
|
|||||||
} else if (log instanceof PostFeaturedChangeLog f) {
|
} else if (log instanceof PostFeaturedChangeLog f) {
|
||||||
dto.setOldFeatured(f.isOldFeatured());
|
dto.setOldFeatured(f.isOldFeatured());
|
||||||
dto.setNewFeatured(f.isNewFeatured());
|
dto.setNewFeatured(f.isNewFeatured());
|
||||||
} else if (log instanceof PostVisibleScopeChangeLog v) {
|
|
||||||
dto.setOldVisibleScope(v.getOldVisibleScope());
|
|
||||||
dto.setNewVisibleScope(v.getNewVisibleScope());
|
|
||||||
} else if (log instanceof PostDonateChangeLog d) {
|
|
||||||
dto.setAmount(d.getAmount());
|
|
||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import com.openisle.dto.LotteryDto;
|
|||||||
import com.openisle.dto.PollDto;
|
import com.openisle.dto.PollDto;
|
||||||
import com.openisle.dto.PostDetailDto;
|
import com.openisle.dto.PostDetailDto;
|
||||||
import com.openisle.dto.PostSummaryDto;
|
import com.openisle.dto.PostSummaryDto;
|
||||||
import com.openisle.dto.ProposalDto;
|
|
||||||
import com.openisle.dto.ReactionDto;
|
import com.openisle.dto.ReactionDto;
|
||||||
import com.openisle.model.CategoryProposalPost;
|
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.model.LotteryPost;
|
import com.openisle.model.LotteryPost;
|
||||||
import com.openisle.model.PollPost;
|
import com.openisle.model.PollPost;
|
||||||
@@ -75,7 +73,6 @@ public class PostMapper {
|
|||||||
dto.setPinnedAt(post.getPinnedAt());
|
dto.setPinnedAt(post.getPinnedAt());
|
||||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||||
dto.setClosed(post.isClosed());
|
dto.setClosed(post.isClosed());
|
||||||
dto.setVisibleScope(post.getVisibleScope());
|
|
||||||
|
|
||||||
List<ReactionDto> reactions = reactionService
|
List<ReactionDto> reactions = reactionService
|
||||||
.getReactionsForPost(post.getId())
|
.getReactionsForPost(post.getId())
|
||||||
@@ -116,40 +113,26 @@ public class PostMapper {
|
|||||||
dto.setLottery(l);
|
dto.setLottery(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (post instanceof CategoryProposalPost cp) {
|
if (post instanceof PollPost pp) {
|
||||||
ProposalDto proposalDto = (ProposalDto) buildPollDto(cp, new ProposalDto());
|
PollDto p = new PollDto();
|
||||||
proposalDto.setProposalStatus(cp.getProposalStatus());
|
p.setOptions(pp.getOptions());
|
||||||
proposalDto.setProposedName(cp.getProposedName());
|
p.setVotes(pp.getVotes());
|
||||||
proposalDto.setDescription(cp.getDescription());
|
p.setEndTime(pp.getEndTime());
|
||||||
proposalDto.setApproveThreshold(cp.getApproveThreshold());
|
p.setParticipants(
|
||||||
proposalDto.setQuorum(cp.getQuorum());
|
pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())
|
||||||
proposalDto.setStartAt(cp.getStartAt());
|
);
|
||||||
proposalDto.setResultSnapshot(cp.getResultSnapshot());
|
Map<Integer, List<AuthorDto>> optionParticipants = pollVoteRepository
|
||||||
proposalDto.setRejectReason(cp.getRejectReason());
|
.findByPostId(pp.getId())
|
||||||
dto.setPoll(proposalDto);
|
.stream()
|
||||||
} else if (post instanceof PollPost pp) {
|
.collect(
|
||||||
dto.setPoll(buildPollDto(pp, new PollDto()));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.openisle.model;
|
|
||||||
|
|
||||||
public enum CategoryProposalStatus {
|
|
||||||
PENDING,
|
|
||||||
APPROVED,
|
|
||||||
REJECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -46,14 +46,8 @@ public enum NotificationType {
|
|||||||
POLL_RESULT_OWNER,
|
POLL_RESULT_OWNER,
|
||||||
/** A poll you participated in has concluded */
|
/** A poll you participated in has concluded */
|
||||||
POLL_RESULT_PARTICIPANT,
|
POLL_RESULT_PARTICIPANT,
|
||||||
/** Your category proposal has concluded */
|
|
||||||
CATEGORY_PROPOSAL_RESULT_OWNER,
|
|
||||||
/** A category proposal you participated in has concluded */
|
|
||||||
CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
|
||||||
/** Your post was featured */
|
/** Your post was featured */
|
||||||
POST_FEATURED,
|
POST_FEATURED,
|
||||||
/** Someone donated to your post */
|
|
||||||
DONATION,
|
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION,
|
MENTION,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,4 @@ public enum PointHistoryType {
|
|||||||
REDEEM,
|
REDEEM,
|
||||||
LOTTERY_JOIN,
|
LOTTERY_JOIN,
|
||||||
LOTTERY_REWARD,
|
LOTTERY_REWARD,
|
||||||
DONATE_SENT,
|
|
||||||
DONATE_RECEIVED,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ public class Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private PostType type = PostType.NORMAL;
|
private PostType type = PostType.NORMAL;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(nullable = false)
|
|
||||||
private PostVisibleScopeType visibleScope = PostVisibleScopeType.ALL;
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private boolean closed = false;
|
private boolean closed = false;
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ public enum PostChangeType {
|
|||||||
CLOSED,
|
CLOSED,
|
||||||
PINNED,
|
PINNED,
|
||||||
FEATURED,
|
FEATURED,
|
||||||
VISIBLE_SCOPE,
|
|
||||||
VOTE_RESULT,
|
VOTE_RESULT,
|
||||||
LOTTERY_RESULT,
|
LOTTERY_RESULT,
|
||||||
DONATE,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.openisle.model;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@NoArgsConstructor
|
|
||||||
@Entity
|
|
||||||
@Table(name = "post_donate_change_logs")
|
|
||||||
public class PostDonateChangeLog extends PostChangeLog {
|
|
||||||
|
|
||||||
@Column(nullable = false)
|
|
||||||
private int amount;
|
|
||||||
}
|
|
||||||
@@ -4,5 +4,4 @@ public enum PostType {
|
|||||||
NORMAL,
|
NORMAL,
|
||||||
LOTTERY,
|
LOTTERY,
|
||||||
POLL,
|
POLL,
|
||||||
PROPOSAL
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.openisle.repository;
|
|
||||||
|
|
||||||
import com.openisle.model.CategoryProposalPost;
|
|
||||||
import com.openisle.model.CategoryProposalStatus;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
|
|
||||||
public interface CategoryProposalPostRepository extends JpaRepository<CategoryProposalPost, Long> {
|
|
||||||
List<CategoryProposalPost> findByEndTimeAfterAndProposalStatus(
|
|
||||||
LocalDateTime now,
|
|
||||||
CategoryProposalStatus status
|
|
||||||
);
|
|
||||||
List<CategoryProposalPost> findByEndTimeBeforeAndProposalStatus(
|
|
||||||
LocalDateTime now,
|
|
||||||
CategoryProposalStatus status
|
|
||||||
);
|
|
||||||
boolean existsByProposedNameIgnoreCase(String proposedName);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package com.openisle.repository;
|
|||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
@@ -11,10 +10,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
public interface CommentRepository extends JpaRepository<Comment, Long> {
|
||||||
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
List<Comment> findByPostAndParentIsNullOrderByCreatedAtAsc(Post post);
|
||||||
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
List<Comment> findByParentOrderByCreatedAtAsc(Comment parent);
|
||||||
List<Comment> findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
|
||||||
Post post,
|
|
||||||
LocalDateTime createdAt
|
|
||||||
);
|
|
||||||
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
List<Comment> findByAuthorOrderByCreatedAtDesc(User author, Pageable pageable);
|
||||||
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
List<Comment> findByContentContainingIgnoreCase(String keyword);
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ package com.openisle.repository;
|
|||||||
|
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.PointHistory;
|
import com.openisle.model.PointHistory;
|
||||||
import com.openisle.model.PointHistoryType;
|
|
||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
|
|
||||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||||
@@ -24,11 +21,4 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
|||||||
List<PointHistory> findByComment(Comment comment);
|
List<PointHistory> findByComment(Comment comment);
|
||||||
|
|
||||||
List<PointHistory> findByPost(Post post);
|
List<PointHistory> findByPost(Post post);
|
||||||
|
|
||||||
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
|
|
||||||
)
|
|
||||||
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status);
|
||||||
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
List<Post> findByStatusOrderByViewsDesc(PostStatus status, Pageable pageable);
|
||||||
List<Post> findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
|
||||||
PostStatus status,
|
|
||||||
LocalDateTime createdAt
|
|
||||||
);
|
|
||||||
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
List<Post> findByAuthorAndStatusOrderByCreatedAtDesc(
|
||||||
User author,
|
User author,
|
||||||
PostStatus status,
|
PostStatus status,
|
||||||
|
|||||||
@@ -266,27 +266,6 @@ public class CommentService {
|
|||||||
return replies;
|
return replies;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Comment getComment(Long commentId) {
|
|
||||||
log.debug("getComment called for id {}", commentId);
|
|
||||||
return commentRepository
|
|
||||||
.findById(commentId)
|
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Comment> getCommentsBefore(Comment comment) {
|
|
||||||
log.debug("getCommentsBefore called for comment {}", comment.getId());
|
|
||||||
List<Comment> comments = commentRepository.findByPostAndCreatedAtLessThanOrderByCreatedAtAsc(
|
|
||||||
comment.getPost(),
|
|
||||||
comment.getCreatedAt()
|
|
||||||
);
|
|
||||||
log.debug(
|
|
||||||
"getCommentsBefore returning {} comments for comment {}",
|
|
||||||
comments.size(),
|
|
||||||
comment.getId()
|
|
||||||
);
|
|
||||||
return comments;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
public List<Comment> getRecentCommentsByUser(String username, int limit) {
|
||||||
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
log.debug("getRecentCommentsByUser called for user {} with limit {}", username, limit);
|
||||||
User user = userRepository
|
User user = userRepository
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
import com.openisle.dto.DonationDto;
|
|
||||||
import com.openisle.dto.DonationResponse;
|
|
||||||
import com.openisle.exception.FieldException;
|
import com.openisle.exception.FieldException;
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.*;
|
import com.openisle.repository.*;
|
||||||
@@ -10,10 +8,8 @@ import java.util.ArrayList;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,8 +20,6 @@ public class PointService {
|
|||||||
private final PostRepository postRepository;
|
private final PostRepository postRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
private final NotificationService notificationService;
|
|
||||||
private final PostChangeLogService postChangeLogService;
|
|
||||||
|
|
||||||
public int awardForPost(String userName, Long postId) {
|
public int awardForPost(String userName, Long postId) {
|
||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
@@ -278,95 +272,4 @@ public class PointService {
|
|||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
return recalculateUserPoints(user);
|
return recalculateUserPoints(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
|
|
||||||
if (amount <= 0) {
|
|
||||||
throw new FieldException("amount", "打赏积分必须大于0");
|
|
||||||
}
|
|
||||||
User donor = userRepository.findByUsername(donorName).orElseThrow();
|
|
||||||
Post post = postRepository.findById(postId).orElseThrow();
|
|
||||||
User author = post.getAuthor();
|
|
||||||
if (author.getId().equals(donor.getId())) {
|
|
||||||
throw new FieldException("post", "不能给自己打赏");
|
|
||||||
}
|
|
||||||
if (donor.getPoint() < amount) {
|
|
||||||
throw new FieldException("point", "积分不足");
|
|
||||||
}
|
|
||||||
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
|
|
||||||
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
|
|
||||||
notificationService.createNotification(
|
|
||||||
author,
|
|
||||||
NotificationType.DONATION,
|
|
||||||
post,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
donor,
|
|
||||||
null,
|
|
||||||
String.valueOf(amount)
|
|
||||||
);
|
|
||||||
postChangeLogService.recordDonation(post, donor, amount);
|
|
||||||
DonationResponse response = buildDonationResponse(post);
|
|
||||||
response.setBalance(donor.getPoint());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DonationResponse getPostDonations(Long postId) {
|
|
||||||
Post post = postRepository.findById(postId).orElseThrow();
|
|
||||||
return buildDonationResponse(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
private DonationResponse buildDonationResponse(Post post) {
|
|
||||||
List<PointHistory> histories =
|
|
||||||
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
|
|
||||||
post,
|
|
||||||
PointHistoryType.DONATE_RECEIVED
|
|
||||||
);
|
|
||||||
List<DonationDto> donations = histories
|
|
||||||
.stream()
|
|
||||||
.collect(Collectors.collectingAndThen(Collectors.toMap(
|
|
||||||
history -> {
|
|
||||||
User donor = history.getFromUser();
|
|
||||||
if (donor != null && donor.getId() != null) {
|
|
||||||
return "user:" + donor.getId();
|
|
||||||
}
|
|
||||||
return "history:" + history.getId();
|
|
||||||
},
|
|
||||||
history -> {
|
|
||||||
DonationDto dto = new DonationDto();
|
|
||||||
User donor = history.getFromUser();
|
|
||||||
if (donor != null) {
|
|
||||||
dto.setUserId(donor.getId());
|
|
||||||
dto.setUsername(donor.getUsername());
|
|
||||||
dto.setAvatar(donor.getAvatar());
|
|
||||||
}
|
|
||||||
dto.setAmount(history.getAmount());
|
|
||||||
dto.setCreatedAt(history.getCreatedAt());
|
|
||||||
return dto;
|
|
||||||
},
|
|
||||||
(left, right) -> {
|
|
||||||
left.setAmount(left.getAmount() + right.getAmount());
|
|
||||||
if (
|
|
||||||
left.getCreatedAt() == null ||
|
|
||||||
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
|
|
||||||
) {
|
|
||||||
left.setCreatedAt(right.getCreatedAt());
|
|
||||||
}
|
|
||||||
return left;
|
|
||||||
},
|
|
||||||
java.util.LinkedHashMap::new
|
|
||||||
), map -> new java.util.ArrayList<>(map.values())));
|
|
||||||
Long total = pointHistoryRepository.sumAmountByPostAndType(
|
|
||||||
post,
|
|
||||||
PointHistoryType.DONATE_RECEIVED
|
|
||||||
);
|
|
||||||
int safeTotal = 0;
|
|
||||||
if (total != null) {
|
|
||||||
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
|
|
||||||
}
|
|
||||||
DonationResponse response = new DonationResponse();
|
|
||||||
response.setDonations(donations);
|
|
||||||
response.setTotalAmount(safeTotal);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,21 +99,6 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
logRepository.save(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void recordVisibleScopeChange(
|
|
||||||
Post post,
|
|
||||||
User user,
|
|
||||||
PostVisibleScopeType oldVisibleScope,
|
|
||||||
PostVisibleScopeType newVisibleScope
|
|
||||||
) {
|
|
||||||
PostVisibleScopeChangeLog log = new PostVisibleScopeChangeLog();
|
|
||||||
log.setPost(post);
|
|
||||||
log.setUser(user);
|
|
||||||
log.setType(PostChangeType.VISIBLE_SCOPE);
|
|
||||||
log.setOldVisibleScope(oldVisibleScope);
|
|
||||||
log.setNewVisibleScope(newVisibleScope);
|
|
||||||
logRepository.save(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void recordVoteResult(Post post) {
|
public void recordVoteResult(Post post) {
|
||||||
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
PostVoteResultChangeLog log = new PostVoteResultChangeLog();
|
||||||
log.setPost(post);
|
log.setPost(post);
|
||||||
@@ -130,15 +115,6 @@ public class PostChangeLogService {
|
|||||||
logRepository.save(log);
|
logRepository.save(log);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void recordDonation(Post post, User donor, int amount) {
|
|
||||||
PostDonateChangeLog log = new PostDonateChangeLog();
|
|
||||||
log.setPost(post);
|
|
||||||
log.setUser(donor);
|
|
||||||
log.setType(PostChangeType.DONATE);
|
|
||||||
log.setAmount(amount);
|
|
||||||
logRepository.save(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void deleteLogsForPost(Post post) {
|
public void deleteLogsForPost(Post post) {
|
||||||
logRepository.deleteByPost(post);
|
logRepository.deleteByPost(post);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package com.openisle.service;
|
package com.openisle.service;
|
||||||
|
|
||||||
import com.openisle.config.CachingConfig;
|
import com.openisle.config.CachingConfig;
|
||||||
import com.openisle.exception.NotFoundException;
|
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
|
import com.openisle.mapper.PostMapper;
|
||||||
import com.openisle.model.*;
|
import com.openisle.model.*;
|
||||||
import com.openisle.repository.CategoryProposalPostRepository;
|
|
||||||
import com.openisle.repository.CategoryRepository;
|
import com.openisle.repository.CategoryRepository;
|
||||||
import com.openisle.repository.CommentRepository;
|
import com.openisle.repository.CommentRepository;
|
||||||
import com.openisle.repository.LotteryPostRepository;
|
import com.openisle.repository.LotteryPostRepository;
|
||||||
@@ -22,6 +21,7 @@ import com.openisle.service.EmailSender;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
@@ -32,6 +32,7 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.cache.annotation.CacheEvict;
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@@ -53,7 +54,6 @@ public class PostService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
private final LotteryPostRepository lotteryPostRepository;
|
private final LotteryPostRepository lotteryPostRepository;
|
||||||
private final PollPostRepository pollPostRepository;
|
private final PollPostRepository pollPostRepository;
|
||||||
private final CategoryProposalPostRepository categoryProposalPostRepository;
|
|
||||||
private final PollVoteRepository pollVoteRepository;
|
private final PollVoteRepository pollVoteRepository;
|
||||||
private PublishMode publishMode;
|
private PublishMode publishMode;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
@@ -71,17 +71,11 @@ public class PostService {
|
|||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final PostChangeLogService postChangeLogService;
|
private final PostChangeLogService postChangeLogService;
|
||||||
private final PointHistoryRepository pointHistoryRepository;
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
private final CategoryService categoryService;
|
|
||||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
|
||||||
new ConcurrentHashMap<>();
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
private final SearchIndexEventPublisher searchIndexEventPublisher;
|
||||||
|
|
||||||
private static final int DEFAULT_PROPOSAL_APPROVE_THRESHOLD = 60;
|
|
||||||
private static final int DEFAULT_PROPOSAL_QUORUM = 10;
|
|
||||||
private static final long DEFAULT_PROPOSAL_DURATION_DAYS = 3;
|
|
||||||
private static final List<String> DEFAULT_PROPOSAL_OPTIONS = List.of("同意", "反对");
|
|
||||||
|
|
||||||
@Value("${app.website-url:https://www.open-isle.com}")
|
@Value("${app.website-url:https://www.open-isle.com}")
|
||||||
private String websiteUrl;
|
private String websiteUrl;
|
||||||
|
|
||||||
@@ -95,7 +89,6 @@ public class PostService {
|
|||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
LotteryPostRepository lotteryPostRepository,
|
LotteryPostRepository lotteryPostRepository,
|
||||||
PollPostRepository pollPostRepository,
|
PollPostRepository pollPostRepository,
|
||||||
CategoryProposalPostRepository categoryProposalPostRepository,
|
|
||||||
PollVoteRepository pollVoteRepository,
|
PollVoteRepository pollVoteRepository,
|
||||||
NotificationService notificationService,
|
NotificationService notificationService,
|
||||||
SubscriptionService subscriptionService,
|
SubscriptionService subscriptionService,
|
||||||
@@ -114,8 +107,7 @@ public class PostService {
|
|||||||
PointHistoryRepository pointHistoryRepository,
|
PointHistoryRepository pointHistoryRepository,
|
||||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
|
||||||
RedisTemplate redisTemplate,
|
RedisTemplate redisTemplate,
|
||||||
SearchIndexEventPublisher searchIndexEventPublisher,
|
SearchIndexEventPublisher searchIndexEventPublisher
|
||||||
CategoryService categoryService
|
|
||||||
) {
|
) {
|
||||||
this.postRepository = postRepository;
|
this.postRepository = postRepository;
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
@@ -123,7 +115,6 @@ public class PostService {
|
|||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
this.lotteryPostRepository = lotteryPostRepository;
|
this.lotteryPostRepository = lotteryPostRepository;
|
||||||
this.pollPostRepository = pollPostRepository;
|
this.pollPostRepository = pollPostRepository;
|
||||||
this.categoryProposalPostRepository = categoryProposalPostRepository;
|
|
||||||
this.pollVoteRepository = pollVoteRepository;
|
this.pollVoteRepository = pollVoteRepository;
|
||||||
this.notificationService = notificationService;
|
this.notificationService = notificationService;
|
||||||
this.subscriptionService = subscriptionService;
|
this.subscriptionService = subscriptionService;
|
||||||
@@ -144,7 +135,6 @@ public class PostService {
|
|||||||
|
|
||||||
this.redisTemplate = redisTemplate;
|
this.redisTemplate = redisTemplate;
|
||||||
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
this.searchIndexEventPublisher = searchIndexEventPublisher;
|
||||||
this.categoryService = categoryService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -170,24 +160,6 @@ public class PostService {
|
|||||||
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) {
|
||||||
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
applicationContext.getBean(PostService.class).finalizePoll(pp.getId());
|
||||||
}
|
}
|
||||||
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus(
|
|
||||||
now,
|
|
||||||
CategoryProposalStatus.PENDING
|
|
||||||
)) {
|
|
||||||
if (cp.getEndTime() != null) {
|
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
|
||||||
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
|
||||||
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
|
||||||
);
|
|
||||||
scheduledFinalizations.put(cp.getId(), future);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus(
|
|
||||||
now,
|
|
||||||
CategoryProposalStatus.PENDING
|
|
||||||
)) {
|
|
||||||
applicationContext.getBean(PostService.class).finalizeProposal(cp.getId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PublishMode getPublishMode() {
|
public PublishMode getPublishMode() {
|
||||||
@@ -253,7 +225,6 @@ public class PostService {
|
|||||||
String content,
|
String content,
|
||||||
List<Long> tagIds,
|
List<Long> tagIds,
|
||||||
PostType type,
|
PostType type,
|
||||||
PostVisibleScopeType postVisibleScopeType,
|
|
||||||
String prizeDescription,
|
String prizeDescription,
|
||||||
String prizeIcon,
|
String prizeIcon,
|
||||||
Integer prizeCount,
|
Integer prizeCount,
|
||||||
@@ -261,12 +232,10 @@ public class PostService {
|
|||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
java.util.List<String> options,
|
java.util.List<String> options,
|
||||||
Boolean multiple,
|
Boolean multiple
|
||||||
String proposedName,
|
|
||||||
String proposalDescription
|
|
||||||
) {
|
) {
|
||||||
// 限制访问次数
|
// 限制访问次数
|
||||||
boolean limitResult = isPostLimitReached(username);
|
boolean limitResult = postRateLimit(username);
|
||||||
if (!limitResult) {
|
if (!limitResult) {
|
||||||
throw new RateLimitException("Too many posts");
|
throw new RateLimitException("Too many posts");
|
||||||
}
|
}
|
||||||
@@ -309,25 +278,6 @@ public class PostService {
|
|||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
pp.setMultiple(multiple != null && multiple);
|
pp.setMultiple(multiple != null && multiple);
|
||||||
post = pp;
|
post = pp;
|
||||||
} else if (actualType == PostType.PROPOSAL) {
|
|
||||||
CategoryProposalPost cp = new CategoryProposalPost();
|
|
||||||
if (proposedName == null || proposedName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("Proposed name required");
|
|
||||||
}
|
|
||||||
String normalizedName = proposedName.trim();
|
|
||||||
if (categoryProposalPostRepository.existsByProposedNameIgnoreCase(normalizedName)) {
|
|
||||||
throw new IllegalArgumentException("Proposed name already exists: " + normalizedName);
|
|
||||||
}
|
|
||||||
cp.setProposedName(normalizedName);
|
|
||||||
cp.setDescription(proposalDescription);
|
|
||||||
cp.setApproveThreshold(DEFAULT_PROPOSAL_APPROVE_THRESHOLD);
|
|
||||||
cp.setQuorum(DEFAULT_PROPOSAL_QUORUM);
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
cp.setStartAt(now);
|
|
||||||
cp.setEndTime(now.plusDays(DEFAULT_PROPOSAL_DURATION_DAYS));
|
|
||||||
cp.setOptions(new ArrayList<>(DEFAULT_PROPOSAL_OPTIONS));
|
|
||||||
cp.setMultiple(false);
|
|
||||||
post = cp;
|
|
||||||
} else {
|
} else {
|
||||||
post = new Post();
|
post = new Post();
|
||||||
}
|
}
|
||||||
@@ -338,18 +288,8 @@ public class PostService {
|
|||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new HashSet<>(tags));
|
post.setTags(new HashSet<>(tags));
|
||||||
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED);
|
||||||
|
|
||||||
// 什么都没设置的情况下,默认为ALL
|
|
||||||
if (Objects.isNull(postVisibleScopeType)) {
|
|
||||||
post.setVisibleScope(PostVisibleScopeType.ALL);
|
|
||||||
} else {
|
|
||||||
post.setVisibleScope(postVisibleScopeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (post instanceof LotteryPost) {
|
if (post instanceof LotteryPost) {
|
||||||
post = lotteryPostRepository.save((LotteryPost) post);
|
post = lotteryPostRepository.save((LotteryPost) post);
|
||||||
} else if (post instanceof CategoryProposalPost categoryProposalPost) {
|
|
||||||
post = categoryProposalPostRepository.save(categoryProposalPost);
|
|
||||||
} else if (post instanceof PollPost) {
|
} else if (post instanceof PollPost) {
|
||||||
post = pollPostRepository.save((PollPost) post);
|
post = pollPostRepository.save((PollPost) post);
|
||||||
} else {
|
} else {
|
||||||
@@ -404,12 +344,6 @@ public class PostService {
|
|||||||
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
||||||
);
|
);
|
||||||
scheduledFinalizations.put(lp.getId(), future);
|
scheduledFinalizations.put(lp.getId(), future);
|
||||||
} else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) {
|
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
|
||||||
() -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()),
|
|
||||||
java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())
|
|
||||||
);
|
|
||||||
scheduledFinalizations.put(cp.getId(), future);
|
|
||||||
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
} else if (post instanceof PollPost pp && pp.getEndTime() != null) {
|
||||||
ScheduledFuture<?> future = taskScheduler.schedule(
|
ScheduledFuture<?> future = taskScheduler.schedule(
|
||||||
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
() -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()),
|
||||||
@@ -420,110 +354,24 @@ public class PostService {
|
|||||||
if (post.getStatus() == PostStatus.PUBLISHED) {
|
if (post.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(post);
|
searchIndexEventPublisher.publishPostSaved(post);
|
||||||
}
|
}
|
||||||
markPostLimit(author.getUsername());
|
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
|
||||||
@Transactional
|
|
||||||
public void finalizeProposal(Long postId) {
|
|
||||||
scheduledFinalizations.remove(postId);
|
|
||||||
categoryProposalPostRepository
|
|
||||||
.findById(postId)
|
|
||||||
.ifPresent(cp -> {
|
|
||||||
if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0;
|
|
||||||
int approveVotes = 0;
|
|
||||||
if (cp.getVotes() != null) {
|
|
||||||
approveVotes = cp.getVotes().getOrDefault(0, 0);
|
|
||||||
}
|
|
||||||
boolean quorumMet = totalParticipants >= cp.getQuorum();
|
|
||||||
int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0;
|
|
||||||
boolean thresholdMet = approvePercent >= cp.getApproveThreshold();
|
|
||||||
boolean approved = false;
|
|
||||||
String rejectReason = null;
|
|
||||||
if (quorumMet && thresholdMet) {
|
|
||||||
cp.setProposalStatus(CategoryProposalStatus.APPROVED);
|
|
||||||
approved = true;
|
|
||||||
} else {
|
|
||||||
cp.setProposalStatus(CategoryProposalStatus.REJECTED);
|
|
||||||
String reason;
|
|
||||||
if (!quorumMet && !thresholdMet) {
|
|
||||||
reason = "未达到法定人数且赞成率不足";
|
|
||||||
} else if (!quorumMet) {
|
|
||||||
reason = "未达到法定人数";
|
|
||||||
} else {
|
|
||||||
reason = "赞成率不足";
|
|
||||||
}
|
|
||||||
cp.setRejectReason(reason);
|
|
||||||
rejectReason = reason;
|
|
||||||
}
|
|
||||||
cp.setResultSnapshot(
|
|
||||||
"approveVotes=" +
|
|
||||||
approveVotes +
|
|
||||||
", totalParticipants=" +
|
|
||||||
totalParticipants +
|
|
||||||
", approvePercent=" +
|
|
||||||
approvePercent
|
|
||||||
);
|
|
||||||
categoryProposalPostRepository.save(cp);
|
|
||||||
if (approved) {
|
|
||||||
categoryService.createCategory(cp.getProposedName(), cp.getDescription(), "star", null);
|
|
||||||
}
|
|
||||||
if (cp.getAuthor() != null) {
|
|
||||||
notificationService.createNotification(
|
|
||||||
cp.getAuthor(),
|
|
||||||
NotificationType.CATEGORY_PROPOSAL_RESULT_OWNER,
|
|
||||||
cp,
|
|
||||||
null,
|
|
||||||
approved,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
approved ? null : rejectReason
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (User participant : cp.getParticipants()) {
|
|
||||||
if (
|
|
||||||
cp.getAuthor() != null &&
|
|
||||||
java.util.Objects.equals(participant.getId(), cp.getAuthor().getId())
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
notificationService.createNotification(
|
|
||||||
participant,
|
|
||||||
NotificationType.CATEGORY_PROPOSAL_RESULT_PARTICIPANT,
|
|
||||||
cp,
|
|
||||||
null,
|
|
||||||
approved,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
approved ? null : rejectReason
|
|
||||||
);
|
|
||||||
}
|
|
||||||
postChangeLogService.recordVoteResult(cp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查用户是否达到发帖限制
|
* 限制发帖频率
|
||||||
* @param username
|
* @param username
|
||||||
* @return true - 允许发帖,false - 已达限制
|
* @return
|
||||||
*/
|
*/
|
||||||
private boolean isPostLimitReached(String username) {
|
private boolean postRateLimit(String username) {
|
||||||
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
||||||
String result = (String) redisTemplate.opsForValue().get(key);
|
String result = (String) redisTemplate.opsForValue().get(key);
|
||||||
return StringUtils.isEmpty(result);
|
//最近没有创建过文章
|
||||||
}
|
if (StringUtils.isEmpty(result)) {
|
||||||
|
// 限制频率为5分钟
|
||||||
/**
|
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
||||||
* 标记用户发帖,触发limit计时
|
return true;
|
||||||
* @param username
|
}
|
||||||
*/
|
return false;
|
||||||
private void markPostLimit(String username) {
|
|
||||||
String key = CachingConfig.LIMIT_CACHE_NAME + ":posts:" + username;
|
|
||||||
redisTemplate.opsForValue().set(key, "1", Duration.ofMinutes(5));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
|
||||||
@@ -602,9 +450,6 @@ public class PostService {
|
|||||||
pollPostRepository
|
pollPostRepository
|
||||||
.findById(postId)
|
.findById(postId)
|
||||||
.ifPresent(pp -> {
|
.ifPresent(pp -> {
|
||||||
if (pp instanceof CategoryProposalPost) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pp.isResultAnnounced()) {
|
if (pp.isResultAnnounced()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -726,7 +571,7 @@ public class PostService {
|
|||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
if (post.getStatus() != PostStatus.PUBLISHED) {
|
if (post.getStatus() != PostStatus.PUBLISHED) {
|
||||||
if (viewer == null) {
|
if (viewer == null) {
|
||||||
throw new com.openisle.exception.NotFoundException("User not found");
|
throw new com.openisle.exception.NotFoundException("Post not found");
|
||||||
}
|
}
|
||||||
User viewerUser = userRepository
|
User viewerUser = userRepository
|
||||||
.findByUsername(viewer)
|
.findByUsername(viewer)
|
||||||
@@ -770,18 +615,6 @@ public class PostService {
|
|||||||
return listPostsByCategories(null, null, null);
|
return listPostsByCategories(null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Post> listRecentPosts(int minutes) {
|
|
||||||
if (minutes <= 0) {
|
|
||||||
throw new IllegalArgumentException("Minutes must be positive");
|
|
||||||
}
|
|
||||||
LocalDateTime since = LocalDateTime.now().minusMinutes(minutes);
|
|
||||||
List<Post> posts = postRepository.findByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc(
|
|
||||||
PostStatus.PUBLISHED,
|
|
||||||
since
|
|
||||||
);
|
|
||||||
return sortByPinnedAndCreated(posts);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||||||
return listPostsByViews(null, null, page, pageSize);
|
return listPostsByViews(null, null, page, pageSize);
|
||||||
}
|
}
|
||||||
@@ -1169,8 +1002,7 @@ public class PostService {
|
|||||||
Long categoryId,
|
Long categoryId,
|
||||||
String title,
|
String title,
|
||||||
String content,
|
String content,
|
||||||
List<Long> tagIds,
|
java.util.List<Long> tagIds
|
||||||
PostVisibleScopeType postVisibleScopeType
|
|
||||||
) {
|
) {
|
||||||
if (tagIds == null || tagIds.isEmpty()) {
|
if (tagIds == null || tagIds.isEmpty()) {
|
||||||
throw new IllegalArgumentException("At least one tag required");
|
throw new IllegalArgumentException("At least one tag required");
|
||||||
@@ -1202,8 +1034,6 @@ public class PostService {
|
|||||||
post.setContent(content);
|
post.setContent(content);
|
||||||
post.setCategory(category);
|
post.setCategory(category);
|
||||||
post.setTags(new java.util.HashSet<>(tags));
|
post.setTags(new java.util.HashSet<>(tags));
|
||||||
PostVisibleScopeType oldVisibleScope = post.getVisibleScope();
|
|
||||||
post.setVisibleScope(postVisibleScopeType);
|
|
||||||
Post updated = postRepository.save(post);
|
Post updated = postRepository.save(post);
|
||||||
imageUploader.adjustReferences(oldContent, content);
|
imageUploader.adjustReferences(oldContent, content);
|
||||||
notificationService.notifyMentions(content, user, updated, null);
|
notificationService.notifyMentions(content, user, updated, null);
|
||||||
@@ -1225,14 +1055,6 @@ public class PostService {
|
|||||||
if (!oldTags.equals(newTags)) {
|
if (!oldTags.equals(newTags)) {
|
||||||
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
|
||||||
}
|
}
|
||||||
if (!java.util.Objects.equals(oldVisibleScope, postVisibleScopeType)) {
|
|
||||||
postChangeLogService.recordVisibleScopeChange(
|
|
||||||
updated,
|
|
||||||
user,
|
|
||||||
oldVisibleScope,
|
|
||||||
postVisibleScopeType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
if (updated.getStatus() == PostStatus.PUBLISHED) {
|
||||||
searchIndexEventPublisher.publishPostSaved(updated);
|
searchIndexEventPublisher.publishPostSaved(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ spring.jpa.hibernate.ddl-auto=update
|
|||||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||||
spring.data.redis.port=${REDIS_PORT:6379}
|
spring.data.redis.port=${REDIS_PORT:6379}
|
||||||
spring.data.redis.database=${REDIS_DATABASE:0}
|
spring.data.redis.database=${REDIS_DATABASE:0}
|
||||||
spring.data.redis.password=${REDIS_PASS: null}
|
|
||||||
|
|
||||||
# for jwt
|
# for jwt
|
||||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE posts ADD COLUMN visible_scope ENUM('ALL', 'ONLY_ME', 'ONLY_REGISTER') NOT NULL DEFAULT 'ALL'
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
ALTER TABLE category_proposal_posts
|
|
||||||
DROP INDEX idx_category_proposal_posts_slug;
|
|
||||||
|
|
||||||
ALTER TABLE category_proposal_posts
|
|
||||||
DROP COLUMN proposed_slug;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_category_proposal_posts_name
|
|
||||||
ON category_proposal_posts (proposed_name);
|
|
||||||
@@ -76,15 +76,6 @@ class PostControllerTest {
|
|||||||
@MockBean
|
@MockBean
|
||||||
private MedalService medalService;
|
private MedalService medalService;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private CategoryService categoryService;
|
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private TagService tagService;
|
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private PointService pointService;
|
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
private com.openisle.repository.PollVoteRepository pollVoteRepository;
|
||||||
|
|
||||||
@@ -126,11 +117,6 @@ class PostControllerTest {
|
|||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
isNull(),
|
||||||
isNull(),
|
|
||||||
isNull(),
|
|
||||||
isNull(),
|
|
||||||
isNull(),
|
|
||||||
isNull(),
|
|
||||||
isNull()
|
isNull()
|
||||||
)
|
)
|
||||||
).thenReturn(post);
|
).thenReturn(post);
|
||||||
@@ -280,11 +266,6 @@ class PostControllerTest {
|
|||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
any(),
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
any(),
|
|
||||||
any()
|
any()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -53,7 +52,6 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
proposalRepo,
|
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -106,7 +104,6 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -133,7 +130,6 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
proposalRepo,
|
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -199,7 +195,6 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -226,7 +221,6 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
proposalRepo,
|
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -266,11 +260,6 @@ class PostServiceTest {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -284,7 +273,6 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -311,7 +299,6 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
proposalRepo,
|
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
@@ -380,7 +367,6 @@ class PostServiceTest {
|
|||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
CategoryProposalPostRepository proposalRepo = mock(CategoryProposalPostRepository.class);
|
|
||||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
@@ -407,7 +393,6 @@ class PostServiceTest {
|
|||||||
tagRepo,
|
tagRepo,
|
||||||
lotteryRepo,
|
lotteryRepo,
|
||||||
pollPostRepo,
|
pollPostRepo,
|
||||||
proposalRepo,
|
|
||||||
pollVoteRepo,
|
pollVoteRepo,
|
||||||
notifService,
|
notifService,
|
||||||
subService,
|
subService,
|
||||||
|
|||||||
@@ -46,4 +46,3 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x}
|
|||||||
# Web push configuration
|
# Web push configuration
|
||||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||||
app.snippet-length=${SNIPPET_LENGTH:200}
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { Agent, Runner, hostedMcpTool, withTrace } from "@openai/agents";
|
|
||||||
|
|
||||||
export type WorkflowInput = { input_as_text: string };
|
|
||||||
|
|
||||||
export abstract class BotFather {
|
|
||||||
protected readonly allowedMcpTools = [
|
|
||||||
"search",
|
|
||||||
"create_post",
|
|
||||||
"reply_to_post",
|
|
||||||
"reply_to_comment",
|
|
||||||
"recent_posts",
|
|
||||||
"get_post",
|
|
||||||
"list_unread_messages",
|
|
||||||
"mark_notifications_read",
|
|
||||||
"create_post",
|
|
||||||
];
|
|
||||||
|
|
||||||
protected readonly openisleToken = (process.env.OPENISLE_TOKEN ?? "").trim();
|
|
||||||
|
|
||||||
protected readonly mcp = this.createHostedMcpTool();
|
|
||||||
protected readonly agent: Agent;
|
|
||||||
|
|
||||||
constructor(protected readonly name: string) {
|
|
||||||
console.log(`✅ ${this.name} starting...`);
|
|
||||||
console.log(
|
|
||||||
"🛠️ Configured Hosted MCP tools:",
|
|
||||||
this.allowedMcpTools.join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
this.openisleToken
|
|
||||||
? "🔑 OPENISLE_TOKEN detected in environment; it will be attached to MCP requests."
|
|
||||||
: "🔓 OPENISLE_TOKEN not set; authenticated MCP tools may be unavailable."
|
|
||||||
);
|
|
||||||
|
|
||||||
this.agent = new Agent({
|
|
||||||
name: this.name,
|
|
||||||
instructions: this.buildInstructions(),
|
|
||||||
tools: [this.mcp],
|
|
||||||
model: "gpt-4o",
|
|
||||||
modelSettings: {
|
|
||||||
temperature: 0.7,
|
|
||||||
topP: 1,
|
|
||||||
maxTokens: 2048,
|
|
||||||
toolChoice: "auto",
|
|
||||||
store: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected buildInstructions(): string {
|
|
||||||
const instructions = [
|
|
||||||
...this.getBaseInstructions(),
|
|
||||||
...this.getAdditionalInstructions(),
|
|
||||||
].filter(Boolean);
|
|
||||||
return instructions.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getBaseInstructions(): string[] {
|
|
||||||
return [
|
|
||||||
"You are a helpful assistant for https://www.open-isle.com.",
|
|
||||||
"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 summary and include any important URLs or IDs.",
|
|
||||||
"After finishing replies, call mark_notifications_read with all processed notification IDs to keep the inbox clean.",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private createHostedMcpTool() {
|
|
||||||
const token = this.openisleToken;
|
|
||||||
const authConfig = token
|
|
||||||
? {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return hostedMcpTool({
|
|
||||||
serverLabel: "openisle_mcp",
|
|
||||||
serverUrl: "https://www.open-isle.com/mcp",
|
|
||||||
allowedTools: this.allowedMcpTools,
|
|
||||||
requireApproval: "never",
|
|
||||||
...authConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getAdditionalInstructions(): string[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createRunner(): Runner {
|
|
||||||
return new Runner({
|
|
||||||
workflowName: this.name,
|
|
||||||
traceMetadata: {
|
|
||||||
__trace_source__: "agent-builder",
|
|
||||||
workflow_id: "wf_69003cbd47e08190928745d3c806c0b50d1a01cfae052be8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async runWorkflow(workflow: WorkflowInput) {
|
|
||||||
if (!process.env.OPENAI_API_KEY) {
|
|
||||||
throw new Error("Missing OPENAI_API_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = this.createRunner();
|
|
||||||
|
|
||||||
return await withTrace(`${this.name} run`, async () => {
|
|
||||||
const preview = workflow.input_as_text.trim();
|
|
||||||
console.log(
|
|
||||||
"📝 Received workflow input (preview):",
|
|
||||||
preview.length > 200 ? `${preview.slice(0, 200)}…` : preview
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("🚦 Starting agent run with maxTurns=16...");
|
|
||||||
const result = await runner.run(this.agent, workflow.input_as_text, {
|
|
||||||
maxTurns: 16,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("📬 Agent run completed. Result keys:", Object.keys(result));
|
|
||||||
|
|
||||||
if (!result.finalOutput) {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract getCliQuery(): string;
|
|
||||||
|
|
||||||
public async runCli(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const query = this.getCliQuery();
|
|
||||||
console.log("🔍 Running workflow...");
|
|
||||||
await this.runWorkflow({ input_as_text: query });
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error("❌ Agent failed:", err?.stack || err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { BotFather, WorkflowInput } from "../bot_father";
|
|
||||||
|
|
||||||
const WEEKDAY_NAMES = ["日", "一", "二", "三", "四", "五", "六"] as const;
|
|
||||||
|
|
||||||
class CoffeeBot extends BotFather {
|
|
||||||
constructor() {
|
|
||||||
super("Coffee Bot");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getAdditionalInstructions(): string[] {
|
|
||||||
return [
|
|
||||||
"You are responsible for 发布每日抽奖早安贴。",
|
|
||||||
"创建帖子时,确保标题、奖品信息、开奖时间以及领奖方式完全符合 CLI 查询提供的细节。",
|
|
||||||
"正文需亲切友好,简洁明了,鼓励社区成员互动。",
|
|
||||||
"开奖说明需明确告知中奖者需私聊站长 @nagisa 领取奖励。",
|
|
||||||
"确保只发布一个帖子,避免重复调用 create_post。",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getCliQuery(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const beijingNow = new Date(
|
|
||||||
now.toLocaleString("en-US", { timeZone: "Asia/Shanghai" })
|
|
||||||
);
|
|
||||||
const weekday = WEEKDAY_NAMES[beijingNow.getDay()];
|
|
||||||
|
|
||||||
const drawTime = new Date(beijingNow);
|
|
||||||
drawTime.setHours(15, 0, 0, 0);
|
|
||||||
const drawTimeText = drawTime
|
|
||||||
.toLocaleTimeString("zh-CN", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
timeZone: "Asia/Shanghai",
|
|
||||||
})
|
|
||||||
.replace(/^24:/, "00:");
|
|
||||||
|
|
||||||
return `
|
|
||||||
请立即在 https://www.open-isle.com 使用 create_post 发表一篇全新帖子,遵循以下要求:
|
|
||||||
1. 标题固定为「大家星期${weekday}早安--抽一杯咖啡」。
|
|
||||||
2. 正文包含:
|
|
||||||
- 亲切的早安问候;
|
|
||||||
- 明确奖品写作“Coffee x 1”;
|
|
||||||
- 奖品图片链接:https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/0d6a9b33e9ca4fe5a90540187d3f9ecb.png;
|
|
||||||
- 公布开奖时间为今天下午 15:00(北京时间,写成 ${drawTimeText});
|
|
||||||
- 标注“领奖请私聊站长 @nagisa”;
|
|
||||||
- 鼓励大家留言互动。
|
|
||||||
3. 帖子语言使用简体中文,格式可用 Markdown,使关键信息醒目。
|
|
||||||
4. 完成后只输出“已发布咖啡抽奖贴”,不额外生成总结。
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coffeeBot = new CoffeeBot();
|
|
||||||
|
|
||||||
export const runWorkflow = async (workflow: WorkflowInput) => {
|
|
||||||
return coffeeBot.runWorkflow(workflow);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
coffeeBot.runCli();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// reply_bot.ts
|
|
||||||
import { BotFather, WorkflowInput } from "../bot_father";
|
|
||||||
|
|
||||||
class ReplyBot extends BotFather {
|
|
||||||
constructor() {
|
|
||||||
super("OpenIsle Bot");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getAdditionalInstructions(): string[] {
|
|
||||||
return [
|
|
||||||
"You are a helpful and cute assistant for https://www.open-isle.com. Keep the lovable tone with plentiful kawaii kaomoji (颜表情) such as (๑˃ᴗ˂)ﻭ, (•̀ω•́)✧, (。•ᴗ-)_♡, (⁎⁍̴̛ᴗ⁍̴̛⁎), etc., while staying professional and informative.",
|
|
||||||
"OpenIsle 是一个由 Spring Boot + Vue 3 打造的开源社区平台,提供注册登录、OAuth 登录(Google/GitHub/Discord/Twitter)、帖子与评论互动、标签分类、草稿、统计分析、通知消息、全局搜索、Markdown 支持、图片上传(默认腾讯云 COS)、浏览器推送、DiceBear 头像等功能,旨在帮助团队快速搭建属于自己的技术社区。",
|
|
||||||
"回复时请主动结合上述站点背景,为用户提供有洞察力、可执行的建议或答案,并在需要时引用官网 https://www.open-isle.com、GitHub 仓库 https://github.com/nagisa77/OpenIsle 或相关文档链接,避免空泛的安慰或套话。",
|
|
||||||
"When presenting the result, reply in Chinese with a concise yet content-rich summary filled with kaomoji,并清晰列出关键结论、操作步骤、重要 URL 或 ID,确保用户能直接采取行动。",
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override getCliQuery(): string {
|
|
||||||
return `
|
|
||||||
【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)。
|
|
||||||
`.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const replyBot = new ReplyBot();
|
|
||||||
|
|
||||||
export const runWorkflow = async (workflow: WorkflowInput) => {
|
|
||||||
return replyBot.runWorkflow(workflow);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (require.main === module) {
|
|
||||||
replyBot.runCli();
|
|
||||||
}
|
|
||||||
@@ -40,12 +40,12 @@ echo "👉 Build images ..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=production \
|
--build-arg NUXT_ENV=production \
|
||||||
frontend_service mcp
|
frontend_service
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -36,15 +36,16 @@ echo "👉 Pull base images (for image-based services)..."
|
|||||||
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
|
||||||
|
|
||||||
echo "👉 Build images (staging)..."
|
echo "👉 Build images (staging)..."
|
||||||
|
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
build --pull \
|
build --pull \
|
||||||
--build-arg NUXT_ENV=staging \
|
--build-arg NUXT_ENV=staging \
|
||||||
frontend_service mcp
|
frontend_service
|
||||||
|
|
||||||
echo "👉 Recreate & start all target services (no dev profile)..."
|
echo "👉 Recreate & start all target services (no dev profile)..."
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" \
|
docker compose -f "$compose_file" --env-file "$env_file" \
|
||||||
up -d --force-recreate --remove-orphans --no-deps \
|
up -d --force-recreate --remove-orphans --no-deps \
|
||||||
mysql redis rabbitmq websocket-service springboot frontend_service mcp
|
mysql redis rabbitmq websocket-service springboot frontend_service
|
||||||
|
|
||||||
echo "👉 Current status:"
|
echo "👉 Current status:"
|
||||||
docker compose -f "$compose_file" --env-file "$env_file" ps
|
docker compose -f "$compose_file" --env-file "$env_file" ps
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
# OpenSearch Service
|
# OpenSearch Service
|
||||||
opensearch:
|
opensearch:
|
||||||
@@ -65,9 +61,6 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
|
|
||||||
dashboards:
|
dashboards:
|
||||||
image: opensearchproject/opensearch-dashboards:3.0.0
|
image: opensearchproject/opensearch-dashboards:3.0.0
|
||||||
@@ -82,10 +75,6 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
rabbitmq:
|
rabbitmq:
|
||||||
image: rabbitmq:3.13-management
|
image: rabbitmq:3.13-management
|
||||||
@@ -109,10 +98,6 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
@@ -126,10 +111,6 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
|
||||||
springboot:
|
springboot:
|
||||||
@@ -161,8 +142,8 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
# opensearch:
|
opensearch:
|
||||||
# condition: service_healthy
|
condition: service_healthy
|
||||||
command: >
|
command: >
|
||||||
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
|
||||||
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
mvn clean spring-boot:run -Dmaven.test.skip=true"
|
||||||
@@ -174,35 +155,6 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- prod
|
|
||||||
|
|
||||||
mcp:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/mcp.Dockerfile
|
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mcp
|
|
||||||
env_file:
|
|
||||||
- ${ENV_FILE:-../.env}
|
|
||||||
environment:
|
|
||||||
OPENISLE_MCP_BACKEND_BASE_URL: http://springboot:${SERVER_PORT:-8080}
|
|
||||||
OPENISLE_MCP_HOST: 0.0.0.0
|
|
||||||
OPENISLE_MCP_PORT: ${OPENISLE_MCP_PORT:-8085}
|
|
||||||
OPENISLE_MCP_TRANSPORT: ${OPENISLE_MCP_TRANSPORT:-streamable-http}
|
|
||||||
OPENISLE_MCP_REQUEST_TIMEOUT: ${OPENISLE_MCP_REQUEST_TIMEOUT:-10.0}
|
|
||||||
ports:
|
|
||||||
- "${OPENISLE_MCP_PORT:-8085}:${OPENISLE_MCP_PORT:-8085}"
|
|
||||||
depends_on:
|
|
||||||
springboot:
|
|
||||||
condition: service_started
|
|
||||||
networks:
|
|
||||||
- openisle-network
|
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
|
|
||||||
websocket-service:
|
websocket-service:
|
||||||
image: maven:3.9-eclipse-temurin-17
|
image: maven:3.9-eclipse-temurin-17
|
||||||
@@ -234,10 +186,6 @@ services:
|
|||||||
start_period: 60s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
- dev_local_backend
|
|
||||||
- prod
|
|
||||||
|
|
||||||
frontend_dev:
|
frontend_dev:
|
||||||
image: node:20
|
image: node:20
|
||||||
@@ -260,28 +208,6 @@ services:
|
|||||||
- openisle-network
|
- openisle-network
|
||||||
profiles:
|
profiles:
|
||||||
- dev
|
- dev
|
||||||
|
|
||||||
frontend_dev_local_backend:
|
|
||||||
image: node:20
|
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev-local-backend
|
|
||||||
working_dir: /app
|
|
||||||
env_file:
|
|
||||||
- ${ENV_FILE:-../.env}
|
|
||||||
command: sh -c "npm install && npm run dev"
|
|
||||||
volumes:
|
|
||||||
- ../frontend_nuxt:/app
|
|
||||||
- frontend-node-modules:/app/node_modules
|
|
||||||
ports:
|
|
||||||
- "${FRONTEND_PORT:-3000}:3000"
|
|
||||||
depends_on:
|
|
||||||
websocket-service:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- openisle-network
|
|
||||||
profiles:
|
|
||||||
- dev_local_backend
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
|
|
||||||
frontend_service:
|
frontend_service:
|
||||||
build:
|
build:
|
||||||
@@ -300,13 +226,13 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles:
|
profiles: ["staging", "prod"]
|
||||||
- prod
|
|
||||||
|
|
||||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
|
||||||
loopback_8080:
|
loopback_8080:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
|
||||||
|
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
|
||||||
command:
|
command:
|
||||||
- -d
|
- -d
|
||||||
- -d
|
- -d
|
||||||
@@ -317,37 +243,13 @@ services:
|
|||||||
springboot:
|
springboot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
|
profiles: ["dev"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
|
|
||||||
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 启动docker的本机:8080
|
|
||||||
loopback_8080_host:
|
|
||||||
image: alpine/socat
|
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080-host
|
|
||||||
command:
|
|
||||||
- -d
|
|
||||||
- -d
|
|
||||||
- -ly
|
|
||||||
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
|
|
||||||
- TCP4:host.docker.internal:8080
|
|
||||||
network_mode: "service:frontend_dev_local_backend"
|
|
||||||
depends_on:
|
|
||||||
frontend_dev_local_backend:
|
|
||||||
condition: service_started
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 20
|
|
||||||
start_period: 10s
|
|
||||||
profiles:
|
|
||||||
- dev_local_backend
|
|
||||||
|
|
||||||
loopback_8082:
|
loopback_8082:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
@@ -363,37 +265,13 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
network_mode: "service:frontend_dev"
|
network_mode: "service:frontend_dev"
|
||||||
|
profiles: ["dev"]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 20
|
retries: 20
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
profiles:
|
|
||||||
- dev
|
|
||||||
|
|
||||||
loopback_8082_host:
|
|
||||||
image: alpine/socat
|
|
||||||
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082-host
|
|
||||||
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082(WS 纯 TCP 可直接过)
|
|
||||||
command:
|
|
||||||
- -d
|
|
||||||
- -d
|
|
||||||
- -ly
|
|
||||||
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
|
|
||||||
- TCP4:websocket-service:8082
|
|
||||||
depends_on:
|
|
||||||
websocket-service:
|
|
||||||
condition: service_healthy
|
|
||||||
network_mode: "service:frontend_dev_local_backend"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 20
|
|
||||||
start_period: 10s
|
|
||||||
profiles:
|
|
||||||
- dev_local_backend
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
openisle-network:
|
openisle-network:
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
FROM python:3.11-slim AS base
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY mcp/pyproject.toml mcp/README.md ./
|
|
||||||
COPY mcp/src ./src
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir --upgrade pip \
|
|
||||||
&& pip install --no-cache-dir .
|
|
||||||
|
|
||||||
ENV OPENISLE_MCP_HOST=0.0.0.0 \
|
|
||||||
OPENISLE_MCP_PORT=8085 \
|
|
||||||
OPENISLE_MCP_TRANSPORT=streamable-http
|
|
||||||
|
|
||||||
EXPOSE 8085
|
|
||||||
|
|
||||||
CMD ["openisle-mcp"]
|
|
||||||
|
|
||||||
@@ -41,13 +41,10 @@ import GlobalPopups from '~/components/GlobalPopups.vue'
|
|||||||
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
||||||
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import { checkToken } from '~/utils/auth'
|
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const menuVisible = ref(!isMobile.value)
|
const menuVisible = ref(!isMobile.value)
|
||||||
|
|
||||||
await checkToken()
|
|
||||||
|
|
||||||
const showNewPostIcon = computed(() => useRoute().path === '/')
|
const showNewPostIcon = computed(() => useRoute().path === '/')
|
||||||
|
|
||||||
const hideMenu = computed(() => {
|
const hideMenu = computed(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
--primary-color: rgb(10, 110, 120);
|
--primary-color: rgb(10, 110, 120);
|
||||||
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
--primary-color-disabled: rgba(93, 152, 156, 0.5);
|
||||||
--secondary-color: rgb(255, 255, 255);
|
--secondary-color: rgb(255, 255, 255);
|
||||||
--secondary-color-hover: rgba(10, 111, 120, 0.079);
|
--secondary-color-hover: rgba(10, 111, 120, 0.184);
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-height: 60px;
|
--header-height: 60px;
|
||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
@@ -54,7 +54,6 @@
|
|||||||
--header-border-color: #555;
|
--header-border-color: #555;
|
||||||
--primary-color: rgb(17, 182, 197);
|
--primary-color: rgb(17, 182, 197);
|
||||||
--primary-color-hover: rgb(13, 137, 151);
|
--primary-color-hover: rgb(13, 137, 151);
|
||||||
--secondary-color-hover: rgba(17, 182, 197, 0.238);
|
|
||||||
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
--new-post-icon-color: rgba(10, 111, 120, 0.598);
|
||||||
--header-text-color: white;
|
--header-text-color: white;
|
||||||
--app-menu-background-color: #333;
|
--app-menu-background-color: #333;
|
||||||
@@ -180,7 +179,7 @@ body {
|
|||||||
|
|
||||||
.info-content-text pre .line-numbers {
|
.info-content-text pre .line-numbers {
|
||||||
counter-reset: line-number 0;
|
counter-reset: line-number 0;
|
||||||
white-space: nowrap; /* 禁止数字换行 */
|
white-space: nowrap; /* 禁止数字换行 */
|
||||||
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
||||||
/* width: 2em; */
|
/* width: 2em; */
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -206,6 +205,7 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: var(--code-highlight-background-color);
|
background-color: var(--code-highlight-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
white-space: pre; /* 禁止自动换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
@@ -344,7 +344,7 @@ body {
|
|||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*处理iframe视频标签*/
|
/*处理iframe视频标签*/
|
||||||
.info-content-text iframe {
|
.info-content-text iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -370,10 +370,7 @@ body {
|
|||||||
.d2h-code-line {
|
.d2h-code-line {
|
||||||
padding-left: 10px !important;
|
padding-left: 10px !important;
|
||||||
}
|
}
|
||||||
/* 手机端不换行 */
|
|
||||||
.info-content-text code {
|
|
||||||
white-space: pre; /* 禁止自动换行 */
|
|
||||||
}
|
|
||||||
/* .d2h-diff-table {
|
/* .d2h-diff-table {
|
||||||
font-size: 6px !important;
|
font-size: 6px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
src: { type: String, default: '' },
|
src: { type: String, required: true },
|
||||||
alt: { type: String, default: '' },
|
alt: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -39,6 +39,9 @@ const placeholder = computed(() => {
|
|||||||
function onLoad() {
|
function onLoad() {
|
||||||
loaded.value = true
|
loaded.value = true
|
||||||
}
|
}
|
||||||
|
function onError() {
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,187 +1,157 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="groupRef"
|
|
||||||
class="base-item-group"
|
class="base-item-group"
|
||||||
:class="groupClass"
|
:style="{
|
||||||
:style="groupStyle"
|
width: `${containerWidth}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
'--base-item-group-duration': `${animationDuration}ms`,
|
||||||
|
}"
|
||||||
@mouseenter="onMouseEnter"
|
@mouseenter="onMouseEnter"
|
||||||
@mouseleave="onMouseLeave"
|
@mouseleave="onMouseLeave"
|
||||||
@focusin="onFocusIn"
|
|
||||||
@focusout="onFocusOut"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in normalizedItems"
|
v-for="(item, index) in items"
|
||||||
:key="resolveKey(item, index)"
|
:key="itemKey(item, index)"
|
||||||
class="base-item-group-item"
|
class="base-item-group__item"
|
||||||
:style="{ zIndex: getZIndex(index) }"
|
:style="{
|
||||||
|
width: `${itemSize}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
transform: `translateX(${index * activeGap}px)`,
|
||||||
|
zIndex: items.length - index,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<slot name="item" :item="item" :index="index"></slot>
|
<slot :item="item" :index="index">
|
||||||
|
<BaseImage
|
||||||
|
v-if="item && (item.src || typeof item === 'string')"
|
||||||
|
class="base-item-group__image"
|
||||||
|
:src="typeof item === 'string' ? item : item.src"
|
||||||
|
:alt="itemAlt(item, index)"
|
||||||
|
/>
|
||||||
|
<div v-else class="base-item-group__placeholder">{{ placeholderText(item) }}</div>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<slot name="after"></slot>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
itemKey: {
|
itemSize: {
|
||||||
type: [String, Function],
|
type: Number,
|
||||||
default: null,
|
default: 40,
|
||||||
},
|
},
|
||||||
overlap: {
|
collapsedGap: {
|
||||||
type: [Number, String],
|
type: Number,
|
||||||
default: 12,
|
default: 12,
|
||||||
},
|
},
|
||||||
expandedGap: {
|
expandedGap: {
|
||||||
type: [Number, String],
|
type: Number,
|
||||||
default: 8,
|
default: null,
|
||||||
},
|
|
||||||
direction: {
|
|
||||||
type: String,
|
|
||||||
default: 'horizontal',
|
|
||||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
|
||||||
},
|
|
||||||
reverse: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
},
|
||||||
animationDuration: {
|
animationDuration: {
|
||||||
type: [Number, String],
|
type: Number,
|
||||||
default: 200,
|
default: 200,
|
||||||
},
|
},
|
||||||
})
|
itemKeyField: {
|
||||||
|
type: String,
|
||||||
const groupRef = ref(null)
|
default: 'id',
|
||||||
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(() => ({
|
const isHovered = ref(false)
|
||||||
'--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)
|
const onMouseEnter = () => {
|
||||||
|
isHovered.value = true
|
||||||
function onMouseEnter() {
|
|
||||||
state.hovering = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseLeave() {
|
const onMouseLeave = () => {
|
||||||
state.hovering = false
|
isHovered.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFocusIn() {
|
const effectiveExpandedGap = computed(() =>
|
||||||
state.focused = true
|
props.expandedGap == null ? props.itemSize : props.expandedGap,
|
||||||
}
|
)
|
||||||
|
|
||||||
function onFocusOut(event) {
|
const activeGap = computed(() =>
|
||||||
const nextTarget = event.relatedTarget
|
isHovered.value ? effectiveExpandedGap.value : props.collapsedGap,
|
||||||
if (!groupRef.value) {
|
)
|
||||||
state.focused = false
|
|
||||||
return
|
const containerWidth = computed(() =>
|
||||||
|
props.items.length ? props.itemSize + (props.items.length - 1) * activeGap.value : props.itemSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (effectiveExpandedGap.value < props.collapsedGap) {
|
||||||
|
console.warn('[BaseItemGroup] `expandedGap` should be greater than or equal to `collapsedGap`.')
|
||||||
}
|
}
|
||||||
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
|
})
|
||||||
state.focused = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveKey(item, index) {
|
const itemKey = (item, index) => {
|
||||||
if (typeof props.itemKey === 'function') {
|
if (item && typeof item === 'object' && props.itemKeyField in item) {
|
||||||
return props.itemKey(item, index)
|
return item[props.itemKeyField]
|
||||||
}
|
|
||||||
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
|
|
||||||
return item[props.itemKey]
|
|
||||||
}
|
}
|
||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
function getZIndex(index) {
|
const itemAlt = (item, index) => {
|
||||||
if (props.reverse) {
|
if (item && typeof item === 'object') {
|
||||||
return index + 1
|
return item.alt || `item-${index}`
|
||||||
}
|
}
|
||||||
return normalizedItems.value.length - index
|
if (typeof item === 'string') {
|
||||||
|
return `item-${index}`
|
||||||
|
}
|
||||||
|
return 'item'
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderText = (item) => {
|
||||||
|
if (item == null) return ''
|
||||||
|
if (typeof item === 'object' && 'text' in item) return item.text
|
||||||
|
return String(item)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.base-item-group {
|
.base-item-group {
|
||||||
--base-item-group-overlap: 12px;
|
display: flex;
|
||||||
--base-item-group-expanded-gap: 8px;
|
|
||||||
--base-item-group-transition-duration: 200ms;
|
|
||||||
display: inline-flex;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
align-items: center;
|
transition: width var(--base-item-group-duration) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-item-group:focus-within {
|
.base-item-group__item {
|
||||||
outline: none;
|
position: absolute;
|
||||||
}
|
top: 0;
|
||||||
|
left: 0;
|
||||||
.base-item-group--horizontal {
|
display: flex;
|
||||||
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-neutral-100, #f0f2f5);
|
||||||
|
transition: transform var(--base-item-group-duration) ease;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-surface, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
.base-item-group__image {
|
||||||
margin-left: calc(var(--base-item-group-overlap) * -1);
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
|
.base-item-group__placeholder {
|
||||||
margin-left: var(--base-item-group-expanded-gap);
|
width: 100%;
|
||||||
}
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
align-items: center;
|
||||||
margin-top: calc(var(--base-item-group-overlap) * -1);
|
justify-content: center;
|
||||||
}
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
|
color: var(--color-neutral-500, #666);
|
||||||
margin-top: var(--base-item-group-expanded-gap);
|
background-color: var(--color-neutral-200, #e5e7eb);
|
||||||
}
|
|
||||||
|
|
||||||
.base-item-group.is-expanded .base-item-group-item {
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<NuxtLink
|
||||||
|
:to="resolvedLink"
|
||||||
class="base-user-avatar"
|
class="base-user-avatar"
|
||||||
:class="wrapperClass"
|
:class="wrapperClass"
|
||||||
:style="wrapperStyle"
|
:style="wrapperStyle"
|
||||||
v-bind="wrapperAttrs"
|
v-bind="wrapperAttrs"
|
||||||
@click="handleClick"
|
|
||||||
>
|
>
|
||||||
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
|
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
|
||||||
</div>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useAttrs } from 'vue'
|
import { useAttrs } from 'vue'
|
||||||
import BaseImage from './BaseImage.vue'
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
|
const DEFAULT_AVATAR = '/default-avatar.svg'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
userId: {
|
userId: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
@@ -48,6 +50,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
const currentSrc = ref(props.src || DEFAULT_AVATAR)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.src,
|
||||||
|
(value) => {
|
||||||
|
currentSrc.value = value || DEFAULT_AVATAR
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const resolvedLink = computed(() => {
|
const resolvedLink = computed(() => {
|
||||||
if (props.to) return props.to
|
if (props.to) return props.to
|
||||||
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
||||||
@@ -59,16 +70,10 @@ const resolvedLink = computed(() => {
|
|||||||
const altText = computed(() => props.alt || '用户头像')
|
const altText = computed(() => props.alt || '用户头像')
|
||||||
|
|
||||||
const sizeStyle = computed(() => {
|
const sizeStyle = computed(() => {
|
||||||
var style = {}
|
if (!props.width && props.width !== 0) return null
|
||||||
|
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||||
if (props.width > 0) {
|
if (!value) return null
|
||||||
style.width = `${props.width}px`
|
return { width: value, height: value }
|
||||||
}
|
|
||||||
if (props.height > 0) {
|
|
||||||
style.height = `${props.height}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
return style
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapperStyle = computed(() => {
|
const wrapperStyle = computed(() => {
|
||||||
@@ -83,9 +88,10 @@ const wrapperAttrs = computed(() => {
|
|||||||
return rest
|
return rest
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleClick = () => {
|
function onError() {
|
||||||
if (props.disableLink) return
|
if (currentSrc.value !== DEFAULT_AVATAR) {
|
||||||
navigateTo(resolvedLink.value)
|
currentSrc.value = DEFAULT_AVATAR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -103,7 +109,7 @@ const handleClick = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.base-user-avatar:hover {
|
.base-user-avatar:hover {
|
||||||
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -488,16 +488,6 @@ const handleContentClick = (e) => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-footer-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 0px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.medal-name {
|
.medal-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="donate-container">
|
|
||||||
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
|
||||||
<div class="donate-viewer" @click="openPanel">
|
|
||||||
<div
|
|
||||||
class="donate-viewer-item-container"
|
|
||||||
@mouseenter="cancelHide"
|
|
||||||
@mouseleave="scheduleHide"
|
|
||||||
>
|
|
||||||
<BaseItemGroup
|
|
||||||
:items="donationList"
|
|
||||||
:overlap="10"
|
|
||||||
:expanded-gap="2"
|
|
||||||
:direction="vertical"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<BaseUserAvatar
|
|
||||||
:user-id="item.userId"
|
|
||||||
:src="item.avatar"
|
|
||||||
:alt="item.username"
|
|
||||||
:width="20"
|
|
||||||
:disable-link="true"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</BaseItemGroup>
|
|
||||||
<div class="donate-counts-text">{{ totalAmount }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ToolTip>
|
|
||||||
<ToolTip content="赞赏作者" placement="bottom" v-else>
|
|
||||||
<div class="donate-viewer-item placeholder" @click="openPanel">
|
|
||||||
<financing class="donate-viewer-item-placeholder-icon" />
|
|
||||||
</div>
|
|
||||||
</ToolTip>
|
|
||||||
<div
|
|
||||||
v-if="panelVisible"
|
|
||||||
class="donate-panel"
|
|
||||||
ref="donatePanelRef"
|
|
||||||
:style="panelInlineStyle"
|
|
||||||
@mouseenter="cancelHide"
|
|
||||||
@mouseleave="scheduleHide"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="option in donateOptions"
|
|
||||||
:key="option"
|
|
||||||
class="donate-option"
|
|
||||||
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
|
||||||
@click="handleDonate(option)"
|
|
||||||
>
|
|
||||||
<financing class="donate-option-icon" />
|
|
||||||
<div class="donate-counts-text">{{ option }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { Finance } from '@icon-park/vue-next'
|
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
import { toast } from '~/main'
|
|
||||||
import { authState, getToken } from '~/utils/auth'
|
|
||||||
|
|
||||||
const financing = Finance
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
postId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
authorId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isAuthor: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
|
||||||
const donatePanelRef = ref(null)
|
|
||||||
const panelInlineStyle = ref({})
|
|
||||||
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
|
||||||
const donating = ref(false)
|
|
||||||
let hideTimer = null
|
|
||||||
|
|
||||||
const donateOptions = [10, 30, 100]
|
|
||||||
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
|
||||||
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
|
||||||
const isAuthorUser = computed(() => {
|
|
||||||
if (props.isAuthor) return true
|
|
||||||
if (!authState.userId || !props.authorId) return false
|
|
||||||
return Number(authState.userId) === Number(props.authorId)
|
|
||||||
})
|
|
||||||
|
|
||||||
const openPanel = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
panelVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
hideTimer = setTimeout(() => {
|
|
||||||
panelVisible.value = false
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
const cancelHide = () => {
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePanelInlineStyle = () => {
|
|
||||||
if (!panelVisible.value) return
|
|
||||||
const panelEl = donatePanelRef.value
|
|
||||||
if (!panelEl) return
|
|
||||||
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
|
|
||||||
if (!parentEl) return
|
|
||||||
const parentWidth = parentEl.clientWidth - 20
|
|
||||||
panelInlineStyle.value = {
|
|
||||||
width: 'max-content',
|
|
||||||
maxWidth: `${parentWidth}px`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(panelVisible, async (visible) => {
|
|
||||||
if (visible) {
|
|
||||||
await nextTick()
|
|
||||||
updatePanelInlineStyle()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const normalizeSummary = (data) => ({
|
|
||||||
totalAmount: data?.totalAmount ?? 0,
|
|
||||||
donations: Array.isArray(data?.donations) ? data.donations : [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadDonations = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
donationSummary.value = normalizeSummary(data)
|
|
||||||
} catch (e) {
|
|
||||||
// ignore network errors for donation summary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDonate = async (amount) => {
|
|
||||||
if (!amount || donating.value) return
|
|
||||||
if (!authState.loggedIn) {
|
|
||||||
toast.error('请先登录后再打赏')
|
|
||||||
panelVisible.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isAuthorUser.value) {
|
|
||||||
toast.warning('不能给自己打赏')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
donating.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ amount }),
|
|
||||||
})
|
|
||||||
const data = await res.json().catch(() => null)
|
|
||||||
if (!res.ok) {
|
|
||||||
if (res.status === 401) {
|
|
||||||
toast.error('请先登录后再打赏')
|
|
||||||
} else {
|
|
||||||
toast.error(data?.error || '打赏失败')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
donationSummary.value = normalizeSummary(data)
|
|
||||||
toast.success('打赏成功,感谢你的支持!')
|
|
||||||
panelVisible.value = false
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('打赏失败,请稍后再试')
|
|
||||||
} finally {
|
|
||||||
donating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
window.addEventListener('resize', updatePanelInlineStyle)
|
|
||||||
await loadDonations()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.postId,
|
|
||||||
async () => {
|
|
||||||
donationSummary.value = { totalAmount: 0, donations: [] }
|
|
||||||
await loadDonations()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.donate-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer-item-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer {
|
|
||||||
border-radius: 13px;
|
|
||||||
padding: 3px;
|
|
||||||
padding-right: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer:hover {
|
|
||||||
background-color: var(--secondary-color-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-counts-text {
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 35px;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
border: 1px solid var(--normal-border-color);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
z-index: 10;
|
|
||||||
gap: 5px;
|
|
||||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer-item.placeholder {
|
|
||||||
display: flex;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 2px 10px;
|
|
||||||
gap: 5px;
|
|
||||||
border: 1px solid var(--normal-border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-color);
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--normal-light-background-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer-item {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-viewer-item-placeholder-icon {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-option {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-option:hover {
|
|
||||||
background-color: var(--normal-light-background-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-option.disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-option.disabled:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donate-option-icon {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.donate-viewer-item.placeholder {
|
|
||||||
padding: 4px 8px;
|
|
||||||
gap: 3px;
|
|
||||||
border: 1px solid var(--normal-border-color);
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: 3px;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-color);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -168,19 +168,9 @@ export default {
|
|||||||
const mobileMenuRef = ref(null)
|
const mobileMenuRef = ref(null)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const openMenu = () => {
|
|
||||||
if (!open.value) {
|
|
||||||
open.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (open.value) {
|
open.value = !open.value
|
||||||
open.value = false
|
if (!open.value) emit('close')
|
||||||
emit('close')
|
|
||||||
} else {
|
|
||||||
open.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
@@ -285,7 +275,7 @@ export default {
|
|||||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
expose({ toggle, close, reload, scrollToBottom })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
@@ -318,6 +308,7 @@ export default {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -340,7 +331,6 @@ export default {
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
min-width: 350px;
|
min-width: 350px;
|
||||||
margin-top: 4px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,6 @@
|
|||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div class="header-content-right">
|
<div class="header-content-right">
|
||||||
<SearchDropdown
|
|
||||||
ref="searchDropdown"
|
|
||||||
v-if="!isMobile || showSearch"
|
|
||||||
@close="closeSearch"
|
|
||||||
/>
|
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||||
<div class="header-icon-item" @click="search">
|
<div class="header-icon-item" @click="search">
|
||||||
@@ -83,9 +78,7 @@
|
|||||||
<div class="header-icon-item" @click="goToMessages">
|
<div class="header-icon-item" @click="goToMessages">
|
||||||
<message-emoji class="header-icon" />
|
<message-emoji class="header-icon" />
|
||||||
<span class="header-label">消息</span>
|
<span class="header-label">消息</span>
|
||||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
|
||||||
unreadMessageCount
|
|
||||||
}}</span>
|
|
||||||
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
@@ -96,9 +89,10 @@
|
|||||||
<BaseUserAvatar
|
<BaseUserAvatar
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
:user-id="authState.userId"
|
:user-id="authState.userId"
|
||||||
:src="authState.avatar"
|
:src="avatar"
|
||||||
:disable-link="true"
|
alt="avatar"
|
||||||
:width="32"
|
:width="32"
|
||||||
|
:disable-link="true"
|
||||||
/>
|
/>
|
||||||
<down />
|
<down />
|
||||||
</div>
|
</div>
|
||||||
@@ -111,6 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -122,7 +117,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
|||||||
import ToolTip from '~/components/ToolTip.vue'
|
import ToolTip from '~/components/ToolTip.vue'
|
||||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import { authState, clearToken } from '~/utils/auth'
|
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -144,11 +139,13 @@ const isLogin = computed(() => authState.loggedIn)
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||||
|
const avatar = ref('')
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = ref(null)
|
const menuBtn = ref(null)
|
||||||
const isCopying = ref(false)
|
const isCopying = ref(false)
|
||||||
|
|
||||||
const onlineCount = ref(0)
|
const onlineCount = ref(0)
|
||||||
|
|
||||||
// 心跳检测
|
// 心跳检测
|
||||||
@@ -211,7 +208,7 @@ const copyInviteLink = async () => {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -255,7 +252,17 @@ const copyRssLink = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goToProfile = async () => {
|
const goToProfile = async () => {
|
||||||
let id = authState.username || authState.id
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
if (id) {
|
if (id) {
|
||||||
navigateTo(`/users/${id}`, { replace: true })
|
navigateTo(`/users/${id}`, { replace: true })
|
||||||
}
|
}
|
||||||
@@ -299,6 +306,14 @@ const iconClass = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
const updateAvatar = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await loadCurrentUser()
|
||||||
|
if (user && user.avatar) {
|
||||||
|
avatar.value = user.avatar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const updateUnread = async () => {
|
const updateUnread = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
fetchUnreadCount()
|
fetchUnreadCount()
|
||||||
@@ -308,8 +323,17 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await updateAvatar()
|
||||||
await updateUnread()
|
await updateUnread()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
async (isLoggedIn) => {
|
||||||
|
await updateAvatar()
|
||||||
|
await updateUnread()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 新增的在线人数逻辑
|
// 新增的在线人数逻辑
|
||||||
sendPing()
|
sendPing()
|
||||||
fetchCount()
|
fetchCount()
|
||||||
@@ -458,6 +482,7 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.invite_text:hover {
|
.invite_text:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -518,10 +543,7 @@ onMounted(async () => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition:
|
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
|
||||||
color 0.25s ease,
|
|
||||||
transform 0.15s ease,
|
|
||||||
opacity 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon-item:hover {
|
.header-icon-item:hover {
|
||||||
@@ -543,7 +565,6 @@ onMounted(async () => {
|
|||||||
.header-label {
|
.header-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 在线人数的数字文字样式(无背景) */
|
/* 在线人数的数字文字样式(无背景) */
|
||||||
@@ -551,14 +572,15 @@ onMounted(async () => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
right: -6px;
|
right: -6px;
|
||||||
color: var(--primary-color); /* 🔹 使用主题主色 */
|
color: var(--primary-color); /* 🔹 使用主题主色 */
|
||||||
background: none; /* 🔹 去掉背景 */
|
background: none; /* 🔹 去掉背景 */
|
||||||
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
||||||
font-weight: 600; /* 加一点权重让数字更醒目 */
|
font-weight: 600; /* 加一点权重让数字更醒目 */
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0; /* 去掉内边距 */
|
padding: 0; /* 去掉内边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes rss-glow {
|
@keyframes rss-glow {
|
||||||
0% {
|
0% {
|
||||||
text-shadow: 0 0 0px var(--primary-color);
|
text-shadow: 0 0 0px var(--primary-color);
|
||||||
|
|||||||
@@ -3,30 +3,15 @@
|
|||||||
<div class="login-overlay-blur"></div>
|
<div class="login-overlay-blur"></div>
|
||||||
<div class="login-overlay-content">
|
<div class="login-overlay-content">
|
||||||
<user-icon class="login-overlay-icon" />
|
<user-icon class="login-overlay-icon" />
|
||||||
<div class="login-overlay-text">{{ props.text }}</div>
|
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||||
<div class="login-overlay-button" @click="goLogin">{{ props.buttonText }}</div>
|
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
const props = defineProps({
|
|
||||||
text: {
|
|
||||||
type: String,
|
|
||||||
default: '请先登录,点击跳转到登录页面',
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
type: String,
|
|
||||||
default: '登录',
|
|
||||||
},
|
|
||||||
buttonLink: {
|
|
||||||
type: String,
|
|
||||||
default: '/login',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const goLogin = () => {
|
const goLogin = () => {
|
||||||
navigateTo(props.buttonLink, { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ export default {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-read-button:hover {
|
.mark-read-button:hover {
|
||||||
@@ -54,7 +53,6 @@ export default {
|
|||||||
|
|
||||||
.has-read-button {
|
.has-read-button {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
<span class="poll-row-title">投票选项</span>
|
<span class="poll-row-title">投票选项</span>
|
||||||
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
<div class="poll-option-item" v-for="(opt, idx) in data.options" :key="idx">
|
||||||
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
<BaseInput v-model="data.options[idx]" placeholder="选项内容" />
|
||||||
<close-icon class="remove-option-icon" @click="removeOption(idx)" />
|
<i
|
||||||
|
v-if="data.options.length > 2"
|
||||||
|
class="fa-solid fa-xmark remove-option-icon"
|
||||||
|
@click="removeOption(idx)"
|
||||||
|
></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="add-option" @click="addOption">添加选项</div>
|
<div class="add-option" @click="addOption">添加选项</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,19 +36,12 @@
|
|||||||
<template v-if="log.newFeatured">将文章设为精选</template>
|
<template v-if="log.newFeatured">将文章设为精选</template>
|
||||||
<template v-else>取消精选文章</template>
|
<template v-else>取消精选文章</template>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="log.type === 'VISIBLE_SCOPE'" class="change-log-content">
|
|
||||||
变更了文章可见范围, 从 {{ formatVisibleScope(log.oldVisibleScope) }} 修改为
|
|
||||||
{{ formatVisibleScope(log.newVisibleScope) }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
<span v-else-if="log.type === 'VOTE_RESULT'" class="change-log-content"
|
||||||
>系统已计算投票结果</span
|
>系统已计算投票结果</span
|
||||||
>
|
>
|
||||||
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
||||||
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
||||||
>
|
>
|
||||||
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
|
|
||||||
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="change-log-time">{{ log.time }}</div>
|
<div class="change-log-time">{{ log.time }}</div>
|
||||||
<div
|
<div
|
||||||
@@ -73,17 +66,6 @@ const props = defineProps({
|
|||||||
title: String,
|
title: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
const VISIBLE_SCOPE_LABELS = {
|
|
||||||
ALL: '全部可见',
|
|
||||||
ONLY_ME: '仅自己可见',
|
|
||||||
ONLY_REGISTER: '仅注册用户可见',
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatVisibleScope = (scope) => {
|
|
||||||
if (!scope) return VISIBLE_SCOPE_LABELS.ALL
|
|
||||||
return VISIBLE_SCOPE_LABELS[scope] ?? scope
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffHtml = computed(() => {
|
const diffHtml = computed(() => {
|
||||||
// Track theme changes
|
// Track theme changes
|
||||||
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
const isDark = import.meta.client && document.documentElement.dataset.theme === 'dark'
|
||||||
|
|||||||
@@ -2,30 +2,6 @@
|
|||||||
<div class="post-poll-container" v-if="poll">
|
<div class="post-poll-container" v-if="poll">
|
||||||
<div class="poll-top-container">
|
<div class="poll-top-container">
|
||||||
<div class="poll-options-container">
|
<div class="poll-options-container">
|
||||||
<div class="poll-title-section">
|
|
||||||
<div class="poll-title-section-row">
|
|
||||||
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
|
||||||
<div class="poll-option-title" v-else-if="isProposal">
|
|
||||||
拟议分类:{{ poll.proposedName }}
|
|
||||||
<ToolTip
|
|
||||||
content="🗳️ 提案提交后将开放3天投票,需达到至少60%的赞成率并满10人参与方可通过。"
|
|
||||||
placement="bottom"
|
|
||||||
v-if="isProposal"
|
|
||||||
>
|
|
||||||
<info-icon class="info-icon" />
|
|
||||||
</ToolTip>
|
|
||||||
</div>
|
|
||||||
<div class="poll-option-title" v-else>单选</div>
|
|
||||||
<div class="poll-left-time">
|
|
||||||
<stopwatch class="poll-left-time-icon" />
|
|
||||||
<div class="poll-left-time-title">离结束</div>
|
|
||||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="poll-title-section-row">
|
|
||||||
<div v-if="poll.description" class="proposal-description">{{ poll.description }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showPollResult || pollEnded || hasVoted">
|
<div v-if="showPollResult || pollEnded || hasVoted">
|
||||||
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
|
||||||
<div class="poll-option-info-container">
|
<div class="poll-option-info-container">
|
||||||
@@ -53,6 +29,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
<div class="poll-title-section">
|
||||||
|
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
||||||
|
<div class="poll-option-title" v-else>单选</div>
|
||||||
|
|
||||||
|
<div class="poll-left-time">
|
||||||
|
<stopwatch class="poll-left-time-icon" />
|
||||||
|
<div class="poll-left-time-title">离结束</div>
|
||||||
|
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template v-if="poll.multiple">
|
<template v-if="poll.multiple">
|
||||||
<div
|
<div
|
||||||
v-for="(opt, idx) in poll.options"
|
v-for="(opt, idx) in poll.options"
|
||||||
@@ -117,6 +103,11 @@
|
|||||||
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||||
<div v-else class="poll-option-hint">
|
<div v-else class="poll-option-hint">
|
||||||
<div>您已投票,等待结束查看结果</div>
|
<div>您已投票,等待结束查看结果</div>
|
||||||
|
<div class="poll-left-time">
|
||||||
|
<stopwatch class="poll-left-time-icon" />
|
||||||
|
<div class="poll-left-time-title">离结束</div>
|
||||||
|
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +130,6 @@ const emit = defineEmits(['refresh'])
|
|||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
const showPollResult = ref(false)
|
const showPollResult = ref(false)
|
||||||
|
|
||||||
const isProposal = computed(() =>
|
|
||||||
Object.prototype.hasOwnProperty.call(props.poll || {}, 'proposedName'),
|
|
||||||
)
|
|
||||||
const pollParticipants = computed(() => props.poll?.participants || [])
|
const pollParticipants = computed(() => props.poll?.participants || [])
|
||||||
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
|
||||||
const pollVotes = computed(() => props.poll?.votes || {})
|
const pollVotes = computed(() => props.poll?.votes || {})
|
||||||
@@ -245,34 +233,6 @@ const submitMultiPoll = async () => {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.proposal-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-status {
|
|
||||||
font-size: 14px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-description {
|
|
||||||
font-size: 16px;
|
|
||||||
margin-top: 10px;
|
|
||||||
line-height: 1.5;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-option-button {
|
.poll-option-button {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -425,18 +385,10 @@ const submitMultiPoll = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.poll-title-section {
|
.poll-title-section {
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.poll-title-section-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
}
|
flex-direction: row;
|
||||||
|
margin-bottom: 20px;
|
||||||
.info-icon {
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-option-title {
|
.poll-option-title {
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default {
|
|||||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||||
{ id: 'PROPOSAL', name: '分类提案', icon: 'tag-one' },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="proposal-section">
|
|
||||||
<div class="proposal-row">
|
|
||||||
<span class="proposal-row-title rule">
|
|
||||||
<info-icon class="proposal-description-title-icon" />提案规则说明</span
|
|
||||||
>
|
|
||||||
<div class="proposal-description-content">
|
|
||||||
<p>📛 拟议分类名称需保持唯一,请勿与现有分类或正在提案中的名称重复。</p>
|
|
||||||
<p>📝 请在下方详细说明提案目的、预期价值及补充材料,方便大家快速理解。</p>
|
|
||||||
<p>🗳️ 提案提交后将开放 3 天投票,需达到至少 60% 的赞成率并满 10 人参与方可通过。</p>
|
|
||||||
<p>🤝 讨论请遵循社区守则,保持礼貌和善,欢迎附上相关案例或参考链接。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="proposal-row">
|
|
||||||
<span class="proposal-row-title">拟议分类名称</span>
|
|
||||||
<BaseInput v-model="data.proposedName" placeholder="请输入分类名称" />
|
|
||||||
</div>
|
|
||||||
<div class="proposal-row">
|
|
||||||
<span class="proposal-row-title">提案描述</span>
|
|
||||||
<BaseInput v-model="data.proposalDescription" placeholder="简要说明提案目的与理由" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
data: { type: Object, required: true },
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.proposal-section {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-row-title {
|
|
||||||
font-size: 16px;
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-row-title.rule {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-activity {
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-description-title-text {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-description-title-icon {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proposal-description-content {
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -18,11 +18,9 @@
|
|||||||
<div>{{ counts[r.type] }}</div>
|
<div>{{ counts[r.type] }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToolTip content="发表心情" placement="bottom">
|
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
</div>
|
||||||
</div>
|
|
||||||
</ToolTip>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="displayedReactions.length">
|
<template v-else-if="displayedReactions.length">
|
||||||
<div
|
<div
|
||||||
@@ -166,7 +164,7 @@ const updatePanelInlineStyle = () => {
|
|||||||
if (!panelVisible.value) return
|
if (!panelVisible.value) return
|
||||||
const panelEl = reactionsPanelRef.value
|
const panelEl = reactionsPanelRef.value
|
||||||
if (!panelEl) return
|
if (!panelEl) return
|
||||||
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
|
const parentEl = panelEl.closest('.reactions-container')?.parentElement
|
||||||
if (!parentEl) return
|
if (!parentEl) return
|
||||||
const parentWidth = parentEl.clientWidth - 20
|
const parentWidth = parentEl.clientWidth - 20
|
||||||
panelInlineStyle.value = {
|
panelInlineStyle.value = {
|
||||||
@@ -322,12 +320,11 @@ onBeforeUnmount(() => {
|
|||||||
.reactions-count {
|
.reactions-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-right: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-panel {
|
.reactions-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 35px;
|
bottom: 40px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -360,6 +357,7 @@ onBeforeUnmount(() => {
|
|||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
placeholder="键盘点击「/」以触发搜索"
|
placeholder="Search"
|
||||||
ref="searchInput"
|
|
||||||
@input="setSearch(keyword)"
|
@input="setSearch(keyword)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +48,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
@@ -62,48 +61,8 @@ const keyword = ref('')
|
|||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const dropdown = ref(null)
|
const dropdown = ref(null)
|
||||||
const searchInput = ref(null)
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
const isEditableElement = (el) => {
|
|
||||||
if (!el) return false
|
|
||||||
if (el.isContentEditable) return true
|
|
||||||
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
|
|
||||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const role = el.getAttribute ? el.getAttribute('role') : null
|
|
||||||
return role === 'textbox'
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusSearchInput = () => {
|
|
||||||
if (!searchInput.value) return
|
|
||||||
dropdown.value?.openMenu?.()
|
|
||||||
if (typeof searchInput.value.focus === 'function') {
|
|
||||||
try {
|
|
||||||
searchInput.value.focus({ preventScroll: true })
|
|
||||||
} catch (e) {
|
|
||||||
searchInput.value.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGlobalSlash = (event) => {
|
|
||||||
if (event.defaultPrevented) return
|
|
||||||
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
|
|
||||||
if (isEditableElement(document.activeElement)) return
|
|
||||||
event.preventDefault()
|
|
||||||
focusSearchInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('keydown', handleGlobalSlash)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener('keydown', handleGlobalSlash)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
dropdown.value.toggle()
|
dropdown.value.toggle()
|
||||||
}
|
}
|
||||||
@@ -185,7 +144,8 @@ defineExpose({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-dropdown {
|
.search-dropdown {
|
||||||
width: 300px;
|
margin-top: 20px;
|
||||||
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-mobile-trigger {
|
.search-mobile-trigger {
|
||||||
@@ -194,7 +154,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
padding: 2px 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -242,7 +202,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-body {
|
.result-body {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -256,14 +216,4 @@ defineExpose({
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 10000;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div class="home-page">
|
||||||
|
<div v-if="!isMobile" class="search-container">
|
||||||
|
<div class="search-title">一切可能,从此刻启航,在此遇见灵感与共鸣</div>
|
||||||
|
<SearchDropdown />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="topic-container">
|
<div class="topic-container">
|
||||||
<div class="topic-item-container">
|
<div class="topic-item-container">
|
||||||
<div
|
<div
|
||||||
@@ -67,13 +72,11 @@
|
|||||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||||
<hands v-else-if="article.type === 'PROPOSAL'" class="proposal-icon" />
|
|
||||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
<lock class="preview-close-icon" v-if="article.isRestricted" />
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
|
<div v-html="sanitizeDescription(article.description)"></div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="article-info-container main-item">
|
<div class="article-info-container main-item">
|
||||||
<ArticleCategory :category="article.category" />
|
<ArticleCategory :category="article.category" />
|
||||||
@@ -113,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
|
|
||||||
<!-- 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
|
||||||
<InfiniteLoadMore
|
<InfiniteLoadMore
|
||||||
v-if="articles.length > 0"
|
v-if="articles.length > 0"
|
||||||
:key="ioKey"
|
:key="ioKey"
|
||||||
@@ -140,7 +143,6 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
|
||||||
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'OpenIsle - 全面开源的自由社区',
|
title: 'OpenIsle - 全面开源的自由社区',
|
||||||
meta: [
|
meta: [
|
||||||
@@ -296,7 +298,6 @@ const {
|
|||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
),
|
),
|
||||||
@@ -338,7 +339,6 @@ const fetchNextPage = async () => {
|
|||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
comments: p.commentCount,
|
comments: p.commentCount,
|
||||||
views: p.views,
|
views: p.views,
|
||||||
isRestricted: p.visibleScope === 'ONLY_ME' || p.visibleScope === 'ONLY_REGISTER',
|
|
||||||
rssExcluded: p.rssExcluded || false,
|
rssExcluded: p.rssExcluded || false,
|
||||||
time: TimeManager.format(
|
time: TimeManager.format(
|
||||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
@@ -378,6 +378,28 @@ onBeforeUnmount(() => {
|
|||||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||||
|
|
||||||
|
// 在首页摘要加载贴吧表情包
|
||||||
|
const sanitizeDescription = (text) => {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
// 1️⃣ 先把 Markdown 转成纯文本
|
||||||
|
const plain = stripMarkdown(text)
|
||||||
|
|
||||||
|
// 2️⃣ 替换 :tieba123: 为 <img>
|
||||||
|
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||||
|
const key = `tieba${num}`
|
||||||
|
const file = tiebaEmoji[key]
|
||||||
|
return file
|
||||||
|
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||||
|
: match // 没有匹配到图片则保留原样
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3️⃣ 可选:截断纯文本长度(防止撑太长)
|
||||||
|
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
|
||||||
// 页面选项同步到全局状态
|
// 页面选项同步到全局状态
|
||||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||||
selectedCategoryGlobal.value = newCategory
|
selectedCategoryGlobal.value = newCategory
|
||||||
@@ -542,14 +564,14 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.header-item.views {
|
.header-item.views {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-time,
|
.article-time,
|
||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-item-title {
|
.article-item-title {
|
||||||
@@ -571,7 +593,6 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.pinned-icon,
|
.pinned-icon,
|
||||||
.lottery-icon,
|
.lottery-icon,
|
||||||
.featured-icon,
|
.featured-icon,
|
||||||
.proposal-icon,
|
|
||||||
.poll-icon {
|
.poll-icon {
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken } from '~/utils/auth'
|
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
@@ -61,6 +61,7 @@ const submitLogin = async () => {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
await navigateTo('/', { replace: true })
|
await navigateTo('/', { replace: true })
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
>
|
>
|
||||||
<div class="conversation-avatar">
|
<div class="conversation-avatar">
|
||||||
<BaseImage
|
<BaseImage
|
||||||
:src="ch.avatar"
|
:src="ch.avatar || '/default-avatar.svg'"
|
||||||
:alt="ch.name"
|
:alt="ch.name"
|
||||||
class="avatar-img"
|
class="avatar-img"
|
||||||
@error="handleAvatarError"
|
@error="handleAvatarError"
|
||||||
@@ -194,7 +194,7 @@ function formatTime(timeString) {
|
|||||||
|
|
||||||
// 头像加载失败处理
|
// 头像加载失败处理
|
||||||
function handleAvatarError(event) {
|
function handleAvatarError(event) {
|
||||||
event.target.src = null
|
event.target.src = '/default-avatar.svg'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChannels() {
|
async function fetchChannels() {
|
||||||
|
|||||||
@@ -75,9 +75,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
<span
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
|
||||||
></span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
回复了
|
回复了
|
||||||
@@ -87,9 +85,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
|
||||||
></span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -119,9 +115,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
|
||||||
></span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
@@ -168,9 +162,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"
|
|
||||||
></span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</span>
|
</span>
|
||||||
进行了表态
|
进行了表态
|
||||||
@@ -259,38 +251,6 @@
|
|||||||
已出结果
|
已出结果
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_OWNER'">
|
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
|
||||||
你的分类提案
|
|
||||||
<NuxtLink
|
|
||||||
class="notif-content-text"
|
|
||||||
@click="markRead(item.id)"
|
|
||||||
:to="`/posts/${item.post.id}`"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-if="item.approved">已通过</span>
|
|
||||||
<span v-else>
|
|
||||||
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
|
||||||
</span>
|
|
||||||
</NotificationContainer>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'">
|
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
|
||||||
你参与的分类提案
|
|
||||||
<NuxtLink
|
|
||||||
class="notif-content-text"
|
|
||||||
@click="markRead(item.id)"
|
|
||||||
:to="`/posts/${item.post.id}`"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
|
||||||
</NuxtLink>
|
|
||||||
<span v-if="item.approved">已通过</span>
|
|
||||||
<span v-else>
|
|
||||||
未通过<span v-if="item.content">,原因:{{ item.content }}</span>
|
|
||||||
</span>
|
|
||||||
</NotificationContainer>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -307,7 +267,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -327,9 +287,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
|
||||||
>
|
>
|
||||||
<span
|
{{ stripMarkdownLength(item.parentComment.content, 100) }}
|
||||||
v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"
|
|
||||||
></span>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
回复了
|
回复了
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -337,7 +295,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -365,7 +323,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -384,7 +342,7 @@
|
|||||||
@click="markRead(item.id)"
|
@click="markRead(item.id)"
|
||||||
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
|
||||||
>
|
>
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
|
{{ stripMarkdownLength(item.comment.content, 100) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -584,27 +542,6 @@
|
|||||||
被收录为精选
|
被收录为精选
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'DONATION'">
|
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
|
||||||
<NuxtLink
|
|
||||||
class="notif-content-text"
|
|
||||||
@click="markRead(item.id)"
|
|
||||||
:to="`/users/${item.fromUser.id}`"
|
|
||||||
>
|
|
||||||
{{ item.fromUser.username }}
|
|
||||||
</NuxtLink>
|
|
||||||
在帖子
|
|
||||||
<NuxtLink
|
|
||||||
class="notif-content-text"
|
|
||||||
@click="markRead(item.id)"
|
|
||||||
:to="`/posts/${item.post.id}`"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
|
||||||
</NuxtLink>
|
|
||||||
打赏了你
|
|
||||||
<template v-if="item.content"> ,获得 {{ item.content }} 积分 </template>
|
|
||||||
</NotificationContainer>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'POST_DELETED'">
|
<template v-else-if="item.type === 'POST_DELETED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
管理员
|
管理员
|
||||||
@@ -619,7 +556,7 @@
|
|||||||
</template>
|
</template>
|
||||||
删除了您的帖子
|
删除了您的帖子
|
||||||
<span class="notif-content-text">
|
<span class="notif-content-text">
|
||||||
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
|
{{ stripMarkdownLength(item.content, 100) }}
|
||||||
</span>
|
</span>
|
||||||
</NotificationContainer>
|
</NotificationContainer>
|
||||||
</template>
|
</template>
|
||||||
@@ -649,7 +586,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
|
|||||||
import BaseTabs from '~/components/BaseTabs.vue'
|
import BaseTabs from '~/components/BaseTabs.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
import {
|
import {
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
fetchUnreadCount,
|
fetchUnreadCount,
|
||||||
@@ -817,10 +754,6 @@ const formatType = (t) => {
|
|||||||
return '发布的投票结果已公布'
|
return '发布的投票结果已公布'
|
||||||
case 'POLL_RESULT_PARTICIPANT':
|
case 'POLL_RESULT_PARTICIPANT':
|
||||||
return '参与的投票结果已公布'
|
return '参与的投票结果已公布'
|
||||||
case 'CATEGORY_PROPOSAL_RESULT_OWNER':
|
|
||||||
return '分类提案结果已公布'
|
|
||||||
case 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT':
|
|
||||||
return '参与的分类提案结果已公布'
|
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
<PostTypeSelect v-model="postType" />
|
<PostTypeSelect v-model="postType" />
|
||||||
<PostVisibleScopeSelect v-model="postVisibleScope"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />
|
||||||
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
<PollForm v-if="postType === 'POLL'" :data="poll" />
|
||||||
<ProposalForm v-if="postType === 'PROPOSAL'" :data="proposal" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,10 +50,8 @@ import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
|||||||
import TagSelect from '~/components/TagSelect.vue'
|
import TagSelect from '~/components/TagSelect.vue'
|
||||||
import LotteryForm from '~/components/LotteryForm.vue'
|
import LotteryForm from '~/components/LotteryForm.vue'
|
||||||
import PollForm from '~/components/PollForm.vue'
|
import PollForm from '~/components/PollForm.vue'
|
||||||
import ProposalForm from '~/components/ProposalForm.vue'
|
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -64,7 +60,6 @@ const content = ref('')
|
|||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const postType = ref('NORMAL')
|
const postType = ref('NORMAL')
|
||||||
const postVisibleScope = ref('ALL')
|
|
||||||
const lottery = reactive({
|
const lottery = reactive({
|
||||||
prizeIcon: '',
|
prizeIcon: '',
|
||||||
prizeIconFile: null,
|
prizeIconFile: null,
|
||||||
@@ -81,10 +76,6 @@ const poll = reactive({
|
|||||||
endTime: null,
|
endTime: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
const proposal = reactive({
|
|
||||||
proposedName: '',
|
|
||||||
proposalDescription: '',
|
|
||||||
})
|
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
@@ -103,7 +94,6 @@ const loadDraft = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.categoryId || ''
|
selectedCategory.value = data.categoryId || ''
|
||||||
selectedTags.value = data.tagIds || []
|
selectedTags.value = data.tagIds || []
|
||||||
postVisibleScope.value = data.visiblescope
|
|
||||||
|
|
||||||
toast.success('草稿已加载')
|
toast.success('草稿已加载')
|
||||||
}
|
}
|
||||||
@@ -119,7 +109,6 @@ const clearPost = async () => {
|
|||||||
content.value = ''
|
content.value = ''
|
||||||
selectedCategory.value = ''
|
selectedCategory.value = ''
|
||||||
selectedTags.value = []
|
selectedTags.value = []
|
||||||
postVisibleScope.value = 'ALL'
|
|
||||||
postType.value = 'NORMAL'
|
postType.value = 'NORMAL'
|
||||||
lottery.prizeIcon = ''
|
lottery.prizeIcon = ''
|
||||||
lottery.prizeIconFile = null
|
lottery.prizeIconFile = null
|
||||||
@@ -134,8 +123,6 @@ const clearPost = async () => {
|
|||||||
poll.options = ['', '']
|
poll.options = ['', '']
|
||||||
poll.endTime = null
|
poll.endTime = null
|
||||||
poll.multiple = false
|
poll.multiple = false
|
||||||
proposal.proposedName = ''
|
|
||||||
proposal.proposalDescription = ''
|
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -173,7 +160,6 @@ const saveDraft = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value || null,
|
categoryId: selectedCategory.value || null,
|
||||||
tagIds,
|
tagIds,
|
||||||
postVisibleScopeType:postVisibleScope.value
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -297,12 +283,6 @@ const submitPost = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (postType.value === 'PROPOSAL') {
|
|
||||||
if (!proposal.proposedName.trim()) {
|
|
||||||
toast.error('请填写拟议分类名称')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
await ensureTags(token)
|
await ensureTags(token)
|
||||||
@@ -323,46 +303,36 @@ const submitPost = async () => {
|
|||||||
}
|
}
|
||||||
prizeIconUrl = uploadData.data.url
|
prizeIconUrl = uploadData.data.url
|
||||||
}
|
}
|
||||||
const toUtcString = (value) => {
|
|
||||||
if (!value) return undefined
|
|
||||||
return new Date(new Date(value).getTime() + 8.02 * 60 * 60 * 1000).toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
categoryId: selectedCategory.value,
|
|
||||||
tagIds: selectedTags.value,
|
|
||||||
type: postType.value,
|
|
||||||
postVisibleScopeType: postVisibleScope.value,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (postType.value === 'LOTTERY') {
|
|
||||||
payload.prizeIcon = prizeIconUrl
|
|
||||||
payload.prizeName = lottery.prizeName
|
|
||||||
payload.prizeCount = lottery.prizeCount
|
|
||||||
payload.prizeDescription = lottery.prizeDescription
|
|
||||||
payload.pointCost = lottery.pointCost
|
|
||||||
payload.startTime = startTime.value ? new Date(startTime.value).toISOString() : undefined
|
|
||||||
payload.endTime = toUtcString(lottery.endTime)
|
|
||||||
} else if (postType.value === 'POLL') {
|
|
||||||
payload.options = poll.options
|
|
||||||
payload.multiple = poll.multiple
|
|
||||||
payload.endTime = toUtcString(poll.endTime)
|
|
||||||
} else if (postType.value === 'PROPOSAL') {
|
|
||||||
payload.proposedName = proposal.proposedName
|
|
||||||
payload.proposalDescription = proposal.proposalDescription
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
const res = await fetch(`${API_BASE_URL}/api/posts`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
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,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
if (data.reward && data.reward > 0) {
|
if (data.reward && data.reward > 0) {
|
||||||
|
|||||||
@@ -184,27 +184,6 @@
|
|||||||
}}</NuxtLink>
|
}}</NuxtLink>
|
||||||
参与,获得 {{ item.amount }} 积分
|
参与,获得 {{ item.amount }} 积分
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'DONATE_SENT'">
|
|
||||||
你在文章
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
中打赏了
|
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
|
||||||
item.fromUserName
|
|
||||||
}}</NuxtLink>
|
|
||||||
,消耗 {{ -item.amount }} 积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'DONATE_RECEIVED'">
|
|
||||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
|
||||||
item.fromUserName
|
|
||||||
}}</NuxtLink>
|
|
||||||
在文章
|
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
|
||||||
item.postTitle
|
|
||||||
}}</NuxtLink>
|
|
||||||
中打赏了你,获得 {{ item.amount }} 积分
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||||
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
<paper-money-two /> 你目前的积分是 {{ item.balance }}
|
||||||
</div>
|
</div>
|
||||||
@@ -269,8 +248,6 @@ const iconMap = {
|
|||||||
FEATURE: 'star',
|
FEATURE: 'star',
|
||||||
LOTTERY_JOIN: 'medal-one',
|
LOTTERY_JOIN: 'medal-one',
|
||||||
LOTTERY_REWARD: 'fireworks',
|
LOTTERY_REWARD: 'fireworks',
|
||||||
DONATE_SENT: 'paper-money-two',
|
|
||||||
DONATE_RECEIVED: 'paper-money-two',
|
|
||||||
POST_LIKE_CANCELLED: 'clear-icon',
|
POST_LIKE_CANCELLED: 'clear-icon',
|
||||||
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
COMMENT_LIKE_CANCELLED: 'clear-icon',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<div class="post-options-left">
|
<div class="post-options-left">
|
||||||
<CategorySelect v-model="selectedCategory" />
|
<CategorySelect v-model="selectedCategory" />
|
||||||
<TagSelect v-model="selectedTags" creatable />
|
<TagSelect v-model="selectedTags" creatable />
|
||||||
<PostVisibleScopeSelect v-model="selectedVisibleScope"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="post-options-right">
|
<div class="post-options-right">
|
||||||
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
<div class="post-clear" @click="clearPost"><clear-icon /> 清空</div>
|
||||||
@@ -45,7 +44,6 @@ import TagSelect from '~/components/TagSelect.vue'
|
|||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { getToken, authState } from '~/utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
import PostVisibleScopeSelect from '~/components/PostVisibleScopeSelect.vue'
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
|
|
||||||
@@ -53,7 +51,6 @@ const title = ref('')
|
|||||||
const content = ref('')
|
const content = ref('')
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
const selectedVisibleScope = ref('ALL')
|
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
const isAiLoading = ref(false)
|
const isAiLoading = ref(false)
|
||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
@@ -73,7 +70,6 @@ const loadPost = async () => {
|
|||||||
content.value = data.content || ''
|
content.value = data.content || ''
|
||||||
selectedCategory.value = data.category.id || ''
|
selectedCategory.value = data.category.id || ''
|
||||||
selectedTags.value = (data.tags || []).map((t) => t.id)
|
selectedTags.value = (data.tags || []).map((t) => t.id)
|
||||||
selectedVisibleScope.value = data.visibleScope
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -184,7 +180,6 @@ const submitPost = async () => {
|
|||||||
content: content.value,
|
content: content.value,
|
||||||
categoryId: selectedCategory.value,
|
categoryId: selectedCategory.value,
|
||||||
tagIds: selectedTags.value,
|
tagIds: selectedTags.value,
|
||||||
postVisibleScopeType:selectedVisibleScope.value
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -1,22 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="isRestricted" class="restricted-content">
|
<div class="post-page-container">
|
||||||
<template v-if="visibleScope === 'ONLY_ME'">
|
|
||||||
<LoginOverlay
|
|
||||||
text="这是一篇私密文章,仅作者本人及管理员可见"
|
|
||||||
button-text="返回首页"
|
|
||||||
button-link="/"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="visibleScope === 'ONLY_REGISTER'">
|
|
||||||
<LoginOverlay
|
|
||||||
text="这是一篇仅登录用户可见的文章,请先登录"
|
|
||||||
button-text="登录"
|
|
||||||
button-link="/login"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
<div v-else class="post-page-container">
|
|
||||||
<div v-if="isWaitingFetchingPost" class="loading-container">
|
<div v-if="isWaitingFetchingPost" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,9 +16,7 @@
|
|||||||
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
|
||||||
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
|
||||||
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
<div v-if="!rssExcluded" class="article-featured-button">精品</div>
|
||||||
<div v-if="closed" class="article-gray-button">已关闭</div>
|
<div v-if="closed" class="article-closed-button">已关闭</div>
|
||||||
<div v-if="visibleScope === 'ONLY_ME'" class="article-gray-button">仅自己可见</div>
|
|
||||||
<div v-if="visibleScope === 'ONLY_REGISTER'" class="article-gray-button">仅登录可见</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
v-if="!closed && loggedIn && !isAuthor && !subscribed"
|
||||||
class="article-subscribe-button"
|
class="article-subscribe-button"
|
||||||
@@ -111,15 +92,12 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<div class="article-option-container">
|
<ReactionsGroup
|
||||||
<ReactionsGroup
|
ref="postReactionsGroupRef"
|
||||||
ref="postReactionsGroupRef"
|
v-model="postReactions"
|
||||||
v-model="postReactions"
|
content-type="post"
|
||||||
content-type="post"
|
:content-id="postId"
|
||||||
:content-id="postId"
|
/>
|
||||||
/>
|
|
||||||
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
|
|
||||||
</div>
|
|
||||||
<div class="article-footer-actions">
|
<div class="article-footer-actions">
|
||||||
<div
|
<div
|
||||||
class="reaction-action like-action"
|
class="reaction-action like-action"
|
||||||
@@ -184,6 +162,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="post-page-scroller-container">
|
||||||
|
<div class="scroller">
|
||||||
|
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||||
|
<div v-else class="scroller-time">{{ scrollerTopTime }}</div>
|
||||||
|
<div class="scroller-middle">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="scroller-range"
|
||||||
|
:max="totalPosts"
|
||||||
|
:min="1"
|
||||||
|
v-model.number="currentIndex"
|
||||||
|
@input="onSliderInput"
|
||||||
|
/>
|
||||||
|
<div class="scroller-index">{{ currentIndex }}/{{ totalPosts }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isWaitingFetchingPost" class="scroller-time">loading...</div>
|
||||||
|
<div v-else class="scroller-time">{{ lastReplyTime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<vue-easy-lightbox
|
<vue-easy-lightbox
|
||||||
:visible="lightboxVisible"
|
:visible="lightboxVisible"
|
||||||
:index="lightboxIndex"
|
:index="lightboxIndex"
|
||||||
@@ -214,7 +211,6 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
|
|||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
import DonateGroup from '~/components/DonateGroup.vue'
|
|
||||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import PostLottery from '~/components/PostLottery.vue'
|
import PostLottery from '~/components/PostLottery.vue'
|
||||||
import PostPoll from '~/components/PostPoll.vue'
|
import PostPoll from '~/components/PostPoll.vue'
|
||||||
@@ -228,7 +224,6 @@ import { useIsMobile } from '~/utils/screen'
|
|||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
import { Lock } from '@icon-park/vue-next'
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -242,13 +237,6 @@ const author = ref('')
|
|||||||
const postContent = ref('')
|
const postContent = ref('')
|
||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
const visibleScope = ref('ALL') // 可见范围
|
|
||||||
const isRestricted = computed(() => {
|
|
||||||
return (
|
|
||||||
(visibleScope.value === 'ONLY_ME' && !isAuthor.value && !isAdmin.value) ||
|
|
||||||
(visibleScope.value === 'ONLY_REGISTER' && !loggedIn.value)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const postReactions = ref([])
|
const postReactions = ref([])
|
||||||
const postReactionsGroupRef = ref(null)
|
const postReactionsGroupRef = ref(null)
|
||||||
const postLikeCount = computed(
|
const postLikeCount = computed(
|
||||||
@@ -416,20 +404,10 @@ const changeLogIcon = (l) => {
|
|||||||
} else {
|
} else {
|
||||||
return 'dislike'
|
return 'dislike'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'VISIBLE_SCOPE') {
|
|
||||||
if (l.newVisibleScope === 'ONLY_ME') {
|
|
||||||
return 'lock-one'
|
|
||||||
} else if (l.newVisibleScope === 'ONLY_REGISTER') {
|
|
||||||
return 'peoples-two'
|
|
||||||
} else {
|
|
||||||
return 'communication'
|
|
||||||
}
|
|
||||||
} else if (l.type === 'VOTE_RESULT') {
|
} else if (l.type === 'VOTE_RESULT') {
|
||||||
return 'check-one'
|
return 'check-one'
|
||||||
} else if (l.type === 'LOTTERY_RESULT') {
|
} else if (l.type === 'LOTTERY_RESULT') {
|
||||||
return 'gift'
|
return 'gift'
|
||||||
} else if (l.type === 'DONATE') {
|
|
||||||
return 'financing'
|
|
||||||
} else {
|
} else {
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
@@ -454,9 +432,6 @@ const mapChangeLog = (l) => ({
|
|||||||
newCategory: l.newCategory,
|
newCategory: l.newCategory,
|
||||||
oldTags: l.oldTags,
|
oldTags: l.oldTags,
|
||||||
newTags: l.newTags,
|
newTags: l.newTags,
|
||||||
oldVisibleScope: l.oldVisibleScope,
|
|
||||||
newVisibleScope: l.newVisibleScope,
|
|
||||||
amount: l.amount,
|
|
||||||
icon: changeLogIcon(l),
|
icon: changeLogIcon(l),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -515,27 +490,15 @@ const onCommentDeleted = (id) => {
|
|||||||
fetchTimeline()
|
fetchTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHeader = computed(() => {
|
|
||||||
const token = getToken()
|
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
|
||||||
})
|
|
||||||
const {
|
const {
|
||||||
data: postData,
|
data: postData,
|
||||||
pending: pendingPost,
|
pending: pendingPost,
|
||||||
error: postError,
|
error: postError,
|
||||||
refresh: refreshPost,
|
refresh: refreshPost,
|
||||||
} = await useAsyncData(
|
} = await useAsyncData(`post-${postId}`, () => $fetch(`${API_BASE_URL}/api/posts/${postId}`), {
|
||||||
`post-${postId}`,
|
server: true,
|
||||||
async () => {
|
lazy: false,
|
||||||
try {
|
})
|
||||||
return await $fetch(`${API_BASE_URL}/api/posts/${postId}`, { headers: tokenHeader.value })
|
|
||||||
} catch (err) {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
server: false,
|
|
||||||
lazy: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
// 用 pendingPost 驱动现有 UI(替代 isWaitingFetchingPost 手控)
|
||||||
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
const isWaitingFetchingPost = computed(() => pendingPost.value)
|
||||||
@@ -549,7 +512,6 @@ watchEffect(() => {
|
|||||||
title.value = data.title
|
title.value = data.title
|
||||||
category.value = data.category
|
category.value = data.category
|
||||||
tags.value = data.tags || []
|
tags.value = data.tags || []
|
||||||
visibleScope.value = data.visibleScope || 'ALL'
|
|
||||||
postReactions.value = data.reactions || []
|
postReactions.value = data.reactions || []
|
||||||
subscribed.value = !!data.subscribed
|
subscribed.value = !!data.subscribed
|
||||||
status.value = data.status
|
status.value = data.status
|
||||||
@@ -966,7 +928,7 @@ onMounted(async () => {
|
|||||||
<style>
|
<style>
|
||||||
.post-page-container {
|
.post-page-container {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
display: block;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,10 +941,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
position: relative;
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
width: calc(100% - 40px);
|
width: calc(85% - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text p code {
|
.info-content-text p code {
|
||||||
@@ -1034,35 +995,6 @@ onMounted(async () => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton {
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
height: 20px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeleton::before {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(90deg, #eee 0%, #f5f5f7 40%, #e0e0e0 100%);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
animation: skeleton-shimmer 1.5s infinite linear;
|
|
||||||
z-index: 1;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
@keyframes skeleton-shimmer {
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar-container {
|
.user-avatar-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -1167,7 +1099,7 @@ onMounted(async () => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-gray-button,
|
.article-closed-button,
|
||||||
.article-subscribe-button-text,
|
.article-subscribe-button-text,
|
||||||
.article-featured-button,
|
.article-featured-button,
|
||||||
.article-unsubscribe-button-text {
|
.article-unsubscribe-button-text {
|
||||||
@@ -1220,7 +1152,7 @@ onMounted(async () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-gray-button {
|
.article-closed-button {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: gray;
|
color: gray;
|
||||||
border: 1px solid gray;
|
border: 1px solid gray;
|
||||||
@@ -1344,14 +1276,6 @@ onMounted(async () => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.article-option-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-footer-actions {
|
.article-footer-actions {
|
||||||
@@ -1402,76 +1326,6 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======== 权限锁定状态 ======== */
|
|
||||||
.is-blurred {
|
|
||||||
filter: blur(10px);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
transition: filter 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 遮罩层 */
|
|
||||||
.restricted-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
animation: fadeIn 0.3s ease forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 中央提示框 */
|
|
||||||
.restricted-content {
|
|
||||||
background: #ffff;
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restricted-icon {
|
|
||||||
font-size: 60px;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restricted-button {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 10px 18px;
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: background 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.restricted-button:hover {
|
|
||||||
background: var(--primary-color-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.restricted-actions {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 淡入动画 */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
@@ -1513,7 +1367,6 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken } from '~/utils/auth'
|
import { loadCurrentUser, setToken } from '~/utils/auth'
|
||||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -172,6 +172,7 @@ const verifyCode = async () => {
|
|||||||
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
|
||||||
toast.success('注册成功')
|
toast.success('注册成功')
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
loadCurrentUser()
|
||||||
navigateTo('/', { replace: true })
|
navigateTo('/', { replace: true })
|
||||||
} else if (data.reason_code === 'VERIFIED') {
|
} else if (data.reason_code === 'VERIFIED') {
|
||||||
if (registerMode.value === 'WHITELIST') {
|
if (registerMode.value === 'WHITELIST') {
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ import {
|
|||||||
Dislike,
|
Dislike,
|
||||||
CheckOne,
|
CheckOne,
|
||||||
Share,
|
Share,
|
||||||
Financing,
|
|
||||||
Hands,
|
|
||||||
PreviewCloseOne,
|
|
||||||
} from '@icon-park/vue-next'
|
} from '@icon-park/vue-next'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
@@ -166,7 +163,4 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('Dislike', Dislike)
|
nuxtApp.vueApp.component('Dislike', Dislike)
|
||||||
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
nuxtApp.vueApp.component('CheckOne', CheckOne)
|
||||||
nuxtApp.vueApp.component('Share', Share)
|
nuxtApp.vueApp.component('Share', Share)
|
||||||
nuxtApp.vueApp.component('Financing', Financing)
|
|
||||||
nuxtApp.vueApp.component('Hands', Hands)
|
|
||||||
nuxtApp.vueApp.component('PreviewCloseOne', PreviewCloseOne)
|
|
||||||
})
|
})
|
||||||
|
|||||||
1
frontend_nuxt/public/default-avatar.svg
Normal file
1
frontend_nuxt/public/default-avatar.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -1,28 +1,33 @@
|
|||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
|
const USER_ID_KEY = 'userId'
|
||||||
|
const USERNAME_KEY = 'username'
|
||||||
|
const ROLE_KEY = 'role'
|
||||||
|
|
||||||
export const authState = reactive({
|
export const authState = reactive({
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
userId: null,
|
userId: null,
|
||||||
username: null,
|
username: null,
|
||||||
role: null,
|
role: null,
|
||||||
avatar: null,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
authState.loggedIn =
|
authState.loggedIn =
|
||||||
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
|
||||||
|
authState.userId = localStorage.getItem(USER_ID_KEY)
|
||||||
|
authState.username = localStorage.getItem(USERNAME_KEY)
|
||||||
|
authState.role = localStorage.getItem(ROLE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setToken(token) {
|
export function setToken(token) {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
localStorage.setItem(TOKEN_KEY, token)
|
localStorage.setItem(TOKEN_KEY, token)
|
||||||
await loadCurrentUser()
|
authState.loggedIn = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,20 +39,26 @@ export function clearToken() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setUserInfo(user) {
|
export function setUserInfo({ id, username }) {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
authState.userId = user.id
|
authState.userId = id
|
||||||
authState.username = user.username
|
authState.username = username
|
||||||
authState.avatar = user.avatar
|
if (arguments[0] && arguments[0].role) {
|
||||||
authState.role = user.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearUserInfo() {
|
export function clearUserInfo() {
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
|
localStorage.removeItem(USER_ID_KEY)
|
||||||
|
localStorage.removeItem(USERNAME_KEY)
|
||||||
|
localStorage.removeItem(ROLE_KEY)
|
||||||
authState.userId = null
|
authState.userId = null
|
||||||
authState.username = null
|
authState.username = null
|
||||||
authState.avatar = null
|
|
||||||
authState.role = null
|
authState.role = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,11 +82,9 @@ export async function fetchCurrentUser() {
|
|||||||
export async function loadCurrentUser() {
|
export async function loadCurrentUser() {
|
||||||
const user = await fetchCurrentUser()
|
const user = await fetchCurrentUser()
|
||||||
if (user) {
|
if (user) {
|
||||||
setUserInfo(user)
|
setUserInfo({ id: user.id, username: user.username, role: user.role })
|
||||||
} else {
|
|
||||||
clearUserInfo()
|
|
||||||
}
|
}
|
||||||
authState.loggedIn = user !== null
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLogin() {
|
export function isLogin() {
|
||||||
@@ -91,12 +100,10 @@ export async function checkToken() {
|
|||||||
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/check`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
authState.loggedIn = res.ok
|
||||||
await setToken(token)
|
return res.ok
|
||||||
} else {
|
|
||||||
clearToken()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearToken()
|
authState.loggedIn = false
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function discordAuthorize(inviteToken = '') {
|
export function discordAuthorize(inviteToken = '') {
|
||||||
@@ -47,6 +47,7 @@ export async function discordExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function githubAuthorize(inviteToken = '') {
|
export function githubAuthorize(inviteToken = '') {
|
||||||
@@ -45,6 +45,7 @@ export async function githubExchange(code, inviteToken = '', reason = '') {
|
|||||||
|
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export async function googleGetIdToken() {
|
export async function googleGetIdToken() {
|
||||||
@@ -79,6 +79,7 @@ export async function googleAuthWithToken(
|
|||||||
|
|
||||||
if (res.ok && data && data.token) {
|
if (res.ok && data && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
if (typeof redirect_success === 'function') redirect_success()
|
if (typeof redirect_success === 'function') redirect_success()
|
||||||
|
|||||||
@@ -265,24 +265,3 @@ export function stripMarkdownLength(text, length) {
|
|||||||
}
|
}
|
||||||
return plain.slice(0, length) + '...'
|
return plain.slice(0, length) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 朴素文本带贴吧表情
|
|
||||||
export function stripMarkdownWithTiebaMoji(text, length){
|
|
||||||
if (!text) return ''
|
|
||||||
|
|
||||||
// Markdown 转成纯文本
|
|
||||||
const plain = stripMarkdown(text)
|
|
||||||
// 替换 :tieba123: 为 <img>
|
|
||||||
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
|
||||||
const key = `tieba${num}`
|
|
||||||
const file = tiebaEmoji[key]
|
|
||||||
return file
|
|
||||||
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
|
||||||
: match // 没有匹配到图片则保留原样
|
|
||||||
})
|
|
||||||
|
|
||||||
// 截断纯文本长度(防止撑太长)
|
|
||||||
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
|
|
||||||
return truncated
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,12 +28,9 @@ const iconMap = {
|
|||||||
POLL_VOTE: 'ChartHistogram',
|
POLL_VOTE: 'ChartHistogram',
|
||||||
POLL_RESULT_OWNER: 'RankingList',
|
POLL_RESULT_OWNER: 'RankingList',
|
||||||
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
POLL_RESULT_PARTICIPANT: 'ChartLine',
|
||||||
CATEGORY_PROPOSAL_RESULT_OWNER: 'TagOne',
|
|
||||||
CATEGORY_PROPOSAL_RESULT_PARTICIPANT: 'TagOne',
|
|
||||||
MENTION: 'HashtagKey',
|
MENTION: 'HashtagKey',
|
||||||
POST_DELETED: 'ClearIcon',
|
POST_DELETED: 'ClearIcon',
|
||||||
POST_FEATURED: 'Star',
|
POST_FEATURED: 'Star',
|
||||||
DONATION: 'PaperMoneyTwo',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchUnreadCount() {
|
export async function fetchUnreadCount() {
|
||||||
@@ -256,9 +253,7 @@ function createFetchNotifications() {
|
|||||||
} else if (
|
} else if (
|
||||||
n.type === 'POLL_VOTE' ||
|
n.type === 'POLL_VOTE' ||
|
||||||
n.type === 'POLL_RESULT_OWNER' ||
|
n.type === 'POLL_RESULT_OWNER' ||
|
||||||
n.type === 'POLL_RESULT_PARTICIPANT' ||
|
n.type === 'POLL_RESULT_PARTICIPANT'
|
||||||
n.type === 'CATEGORY_PROPOSAL_RESULT_OWNER' ||
|
|
||||||
n.type === 'CATEGORY_PROPOSAL_RESULT_PARTICIPANT'
|
|
||||||
) {
|
) {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -339,18 +334,6 @@ function createFetchNotifications() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else if (n.type === 'DONATION') {
|
|
||||||
arr.push({
|
|
||||||
...n,
|
|
||||||
src: n.fromUser ? n.fromUser.avatar : null,
|
|
||||||
icon: n.fromUser ? undefined : iconMap[n.type],
|
|
||||||
iconClick: () => {
|
|
||||||
if (n.post) {
|
|
||||||
markNotificationRead(n.id)
|
|
||||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (n.type === 'REGISTER_REQUEST') {
|
} else if (n.type === 'REGISTER_REQUEST') {
|
||||||
arr.push({
|
arr.push({
|
||||||
...n,
|
...n,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
export function telegramAuthorize(inviteToken = '') {
|
export function telegramAuthorize(inviteToken = '') {
|
||||||
@@ -34,6 +34,7 @@ export async function telegramExchange(authData, inviteToken = '', reason = '')
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush?.()
|
registerPush?.()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { setToken } from './auth'
|
import { setToken, loadCurrentUser } from './auth'
|
||||||
import { registerPush } from './push'
|
import { registerPush } from './push'
|
||||||
|
|
||||||
function generateCodeVerifier() {
|
function generateCodeVerifier() {
|
||||||
@@ -99,6 +99,7 @@ export async function twitterExchange(code, state, reason) {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (res.ok && data.token) {
|
if (res.ok && data.token) {
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
|
await loadCurrentUser()
|
||||||
toast.success('登录成功')
|
toast.success('登录成功')
|
||||||
registerPush()
|
registerPush()
|
||||||
return { success: true, needReason: false }
|
return { success: true, needReason: false }
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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. |
|
|
||||||
| `create_post` | Publish a new post using a JWT token. |
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[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
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""OpenIsle MCP server package."""
|
|
||||||
|
|
||||||
from .config import Settings, get_settings
|
|
||||||
|
|
||||||
__all__ = ["Settings", "get_settings"]
|
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
"""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 PostCreateResult(BaseModel):
|
|
||||||
"""Structured response returned when creating a new post."""
|
|
||||||
|
|
||||||
post: PostDetail = Field(description="Detailed post payload 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.",
|
|
||||||
)
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
"""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 create_post(
|
|
||||||
self,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
*,
|
|
||||||
token: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create a new post and return the detailed backend payload."""
|
|
||||||
|
|
||||||
client = self._get_client()
|
|
||||||
resolved_token = self._require_token(token)
|
|
||||||
headers = self._build_headers(token=resolved_token, include_json=True)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Creating post with category_id=%s and %d tag(s)",
|
|
||||||
payload.get("categoryId"),
|
|
||||||
len(payload.get("tagIds", []) if isinstance(payload.get("tagIds"), list) else []),
|
|
||||||
)
|
|
||||||
response = await client.post(
|
|
||||||
"/api/posts",
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
body = self._ensure_dict(response.json())
|
|
||||||
logger.info("Post creation succeeded with id=%s", 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
|
|
||||||
@@ -1,977 +0,0 @@
|
|||||||
"""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,
|
|
||||||
PostCreateResult,
|
|
||||||
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, create new posts, 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="create_post",
|
|
||||||
description="Publish a new post using an authentication token.",
|
|
||||||
structured_output=True,
|
|
||||||
)
|
|
||||||
async def create_post(
|
|
||||||
title: Annotated[
|
|
||||||
str,
|
|
||||||
PydanticField(description="Title of the post to be created."),
|
|
||||||
],
|
|
||||||
content: Annotated[
|
|
||||||
str,
|
|
||||||
PydanticField(description="Markdown content of the post."),
|
|
||||||
],
|
|
||||||
category_id: Annotated[
|
|
||||||
int | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
ge=1,
|
|
||||||
description="Optional category identifier for the post.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
tag_ids: Annotated[
|
|
||||||
list[int] | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
min_length=1,
|
|
||||||
description="Optional list of tag identifiers to assign to the post.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
post_type: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Optional post type value (e.g. LOTTERY, POLL).",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
visible_scope: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Optional visibility scope for the post.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
prize_description: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Description of the prize for lottery posts.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
prize_icon: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Icon URL for the lottery prize.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
prize_count: Annotated[
|
|
||||||
int | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
ge=1,
|
|
||||||
description="Total number of prizes available for lottery posts.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
point_cost: Annotated[
|
|
||||||
int | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
ge=0,
|
|
||||||
description="Point cost required to participate in the post, when applicable.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
start_time: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="ISO 8601 start time for lottery or poll posts.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
end_time: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="ISO 8601 end time for lottery or poll posts.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
options: Annotated[
|
|
||||||
list[str] | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
min_length=1,
|
|
||||||
description="Poll options when creating a poll post.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
multiple: Annotated[
|
|
||||||
bool | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Whether the poll allows selecting multiple options.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
proposed_name: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Proposed category name for suggestion posts.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
proposal_description: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Supporting description for the proposed category.",
|
|
||||||
),
|
|
||||||
] = None,
|
|
||||||
captcha: Annotated[
|
|
||||||
str | None,
|
|
||||||
PydanticField(
|
|
||||||
default=None,
|
|
||||||
description="Captcha solution if the backend requires one to create posts.",
|
|
||||||
),
|
|
||||||
] = 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,
|
|
||||||
) -> PostCreateResult:
|
|
||||||
"""Create a new post in OpenIsle and return the detailed backend payload."""
|
|
||||||
|
|
||||||
sanitized_title = title.strip()
|
|
||||||
if not sanitized_title:
|
|
||||||
raise ValueError("Post title must not be empty.")
|
|
||||||
|
|
||||||
sanitized_content = content.strip()
|
|
||||||
if not sanitized_content:
|
|
||||||
raise ValueError("Post content must not be empty.")
|
|
||||||
|
|
||||||
sanitized_token = token.strip() if isinstance(token, str) else None
|
|
||||||
if sanitized_token == "":
|
|
||||||
sanitized_token = None
|
|
||||||
|
|
||||||
sanitized_category_id: int | None = None
|
|
||||||
if category_id is not None:
|
|
||||||
if isinstance(category_id, bool):
|
|
||||||
raise ValueError("Category identifier must be an integer, not a boolean.")
|
|
||||||
try:
|
|
||||||
sanitized_category_id = int(category_id)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("Category identifier must be an integer.") from exc
|
|
||||||
if sanitized_category_id <= 0:
|
|
||||||
raise ValueError("Category identifier must be a positive integer.")
|
|
||||||
|
|
||||||
sanitized_tag_ids: list[int] | None = None
|
|
||||||
if tag_ids is not None:
|
|
||||||
sanitized_tag_ids = []
|
|
||||||
for value in tag_ids:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
raise ValueError("Tag identifiers must be integers, not booleans.")
|
|
||||||
try:
|
|
||||||
converted = int(value)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("Tag identifiers must be integers.") from exc
|
|
||||||
if converted <= 0:
|
|
||||||
raise ValueError("Tag identifiers must be positive integers.")
|
|
||||||
sanitized_tag_ids.append(converted)
|
|
||||||
if not sanitized_tag_ids:
|
|
||||||
sanitized_tag_ids = None
|
|
||||||
|
|
||||||
sanitized_post_type = post_type.strip() if isinstance(post_type, str) else None
|
|
||||||
if sanitized_post_type == "":
|
|
||||||
sanitized_post_type = None
|
|
||||||
|
|
||||||
sanitized_visible_scope = (
|
|
||||||
visible_scope.strip() if isinstance(visible_scope, str) else None
|
|
||||||
)
|
|
||||||
if sanitized_visible_scope == "":
|
|
||||||
sanitized_visible_scope = None
|
|
||||||
|
|
||||||
sanitized_prize_description = (
|
|
||||||
prize_description.strip() if isinstance(prize_description, str) else None
|
|
||||||
)
|
|
||||||
if sanitized_prize_description == "":
|
|
||||||
sanitized_prize_description = None
|
|
||||||
|
|
||||||
sanitized_prize_icon = prize_icon.strip() if isinstance(prize_icon, str) else None
|
|
||||||
if sanitized_prize_icon == "":
|
|
||||||
sanitized_prize_icon = None
|
|
||||||
|
|
||||||
sanitized_prize_count: int | None = None
|
|
||||||
if prize_count is not None:
|
|
||||||
if isinstance(prize_count, bool):
|
|
||||||
raise ValueError("Prize count must be an integer, not a boolean.")
|
|
||||||
try:
|
|
||||||
sanitized_prize_count = int(prize_count)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("Prize count must be an integer.") from exc
|
|
||||||
if sanitized_prize_count <= 0:
|
|
||||||
raise ValueError("Prize count must be a positive integer.")
|
|
||||||
|
|
||||||
sanitized_point_cost: int | None = None
|
|
||||||
if point_cost is not None:
|
|
||||||
if isinstance(point_cost, bool):
|
|
||||||
raise ValueError("Point cost must be an integer, not a boolean.")
|
|
||||||
try:
|
|
||||||
sanitized_point_cost = int(point_cost)
|
|
||||||
except (TypeError, ValueError) as exc:
|
|
||||||
raise ValueError("Point cost must be an integer.") from exc
|
|
||||||
if sanitized_point_cost < 0:
|
|
||||||
raise ValueError("Point cost cannot be negative.")
|
|
||||||
|
|
||||||
sanitized_start_time = start_time.strip() if isinstance(start_time, str) else None
|
|
||||||
if sanitized_start_time == "":
|
|
||||||
sanitized_start_time = None
|
|
||||||
|
|
||||||
sanitized_end_time = end_time.strip() if isinstance(end_time, str) else None
|
|
||||||
if sanitized_end_time == "":
|
|
||||||
sanitized_end_time = None
|
|
||||||
|
|
||||||
sanitized_options: list[str] | None = None
|
|
||||||
if options is not None:
|
|
||||||
sanitized_options = []
|
|
||||||
for option in options:
|
|
||||||
if option is None:
|
|
||||||
continue
|
|
||||||
stripped_option = option.strip()
|
|
||||||
if stripped_option:
|
|
||||||
sanitized_options.append(stripped_option)
|
|
||||||
if not sanitized_options:
|
|
||||||
sanitized_options = None
|
|
||||||
|
|
||||||
sanitized_multiple = bool(multiple) if isinstance(multiple, bool) else None
|
|
||||||
|
|
||||||
sanitized_proposed_name = (
|
|
||||||
proposed_name.strip() if isinstance(proposed_name, str) else None
|
|
||||||
)
|
|
||||||
if sanitized_proposed_name == "":
|
|
||||||
sanitized_proposed_name = None
|
|
||||||
|
|
||||||
sanitized_proposal_description = (
|
|
||||||
proposal_description.strip() if isinstance(proposal_description, str) else None
|
|
||||||
)
|
|
||||||
if sanitized_proposal_description == "":
|
|
||||||
sanitized_proposal_description = None
|
|
||||||
|
|
||||||
sanitized_captcha = captcha.strip() if isinstance(captcha, str) else None
|
|
||||||
if sanitized_captcha == "":
|
|
||||||
sanitized_captcha = None
|
|
||||||
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"title": sanitized_title,
|
|
||||||
"content": sanitized_content,
|
|
||||||
}
|
|
||||||
if sanitized_category_id is not None:
|
|
||||||
payload["categoryId"] = sanitized_category_id
|
|
||||||
if sanitized_tag_ids is not None:
|
|
||||||
payload["tagIds"] = sanitized_tag_ids
|
|
||||||
if sanitized_post_type is not None:
|
|
||||||
payload["type"] = sanitized_post_type
|
|
||||||
if sanitized_visible_scope is not None:
|
|
||||||
payload["postVisibleScopeType"] = sanitized_visible_scope
|
|
||||||
if sanitized_prize_description is not None:
|
|
||||||
payload["prizeDescription"] = sanitized_prize_description
|
|
||||||
if sanitized_prize_icon is not None:
|
|
||||||
payload["prizeIcon"] = sanitized_prize_icon
|
|
||||||
if sanitized_prize_count is not None:
|
|
||||||
payload["prizeCount"] = sanitized_prize_count
|
|
||||||
if sanitized_point_cost is not None:
|
|
||||||
payload["pointCost"] = sanitized_point_cost
|
|
||||||
if sanitized_start_time is not None:
|
|
||||||
payload["startTime"] = sanitized_start_time
|
|
||||||
if sanitized_end_time is not None:
|
|
||||||
payload["endTime"] = sanitized_end_time
|
|
||||||
if sanitized_options is not None:
|
|
||||||
payload["options"] = sanitized_options
|
|
||||||
if sanitized_multiple is not None:
|
|
||||||
payload["multiple"] = sanitized_multiple
|
|
||||||
if sanitized_proposed_name is not None:
|
|
||||||
payload["proposedName"] = sanitized_proposed_name
|
|
||||||
if sanitized_proposal_description is not None:
|
|
||||||
payload["proposalDescription"] = sanitized_proposal_description
|
|
||||||
if sanitized_captcha is not None:
|
|
||||||
payload["captcha"] = sanitized_captcha
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info("Creating post with title='%s'", sanitized_title)
|
|
||||||
raw_post = await search_client.create_post(payload, token=sanitized_token)
|
|
||||||
except httpx.HTTPStatusError as exc: # pragma: no cover - network errors
|
|
||||||
status_code = exc.response.status_code
|
|
||||||
if status_code == 400:
|
|
||||||
message = (
|
|
||||||
"Post creation failed due to invalid input or captcha verification errors."
|
|
||||||
)
|
|
||||||
elif status_code == 401:
|
|
||||||
message = "Authentication failed while creating the post. Please verify the token."
|
|
||||||
elif status_code == 403:
|
|
||||||
message = "The provided token is not authorized to create posts."
|
|
||||||
else:
|
|
||||||
message = (
|
|
||||||
"OpenIsle backend returned HTTP "
|
|
||||||
f"{status_code} while creating the post."
|
|
||||||
)
|
|
||||||
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 creation endpoint."
|
|
||||||
if ctx is not None:
|
|
||||||
await ctx.error(message)
|
|
||||||
raise ValueError(message) from exc
|
|
||||||
|
|
||||||
if ctx is not None:
|
|
||||||
await ctx.info(f"Post '{post.title}' created successfully.")
|
|
||||||
logger.debug(
|
|
||||||
"Validated created post payload with id=%s and title='%s'",
|
|
||||||
post.id,
|
|
||||||
post.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PostCreateResult(post=post)
|
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
||||||
@@ -100,28 +100,10 @@ server {
|
|||||||
# auth_basic_user_file /etc/nginx/.htpasswd;
|
# auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
||||||
location ^~ /websocket/ {
|
location ^~ /websocket/ {
|
||||||
proxy_pass http://127.0.0.1:8084/;
|
proxy_pass http://127.0.0.1:8084/;
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /mcp {
|
|
||||||
proxy_pass http://127.0.0.1:8085;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ server {
|
|||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name staging.open-isle.com www.staging.open-isle.com;
|
server_name staging.open-isle.com www.staging.open-isle.com;
|
||||||
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
|
||||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
@@ -37,13 +40,59 @@ server {
|
|||||||
add_header X-Upstream $upstream_addr always;
|
add_header X-Upstream $upstream_addr always;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 1) 原生 WebSocket
|
||||||
|
location ^~ /api/ws {
|
||||||
|
proxy_pass http://127.0.0.1:8081; # 不要尾随 /,保留原样 URI
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# 升级所需
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
||||||
|
location ^~ /api/sockjs {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
|
||||||
|
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
||||||
|
# proxy_hide_header X-Frame-Options;
|
||||||
|
# add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
}
|
||||||
|
|
||||||
# ---------- API ----------
|
# ---------- API ----------
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8081/api/;
|
proxy_pass http://127.0.0.1:8081/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -60,6 +109,7 @@ server {
|
|||||||
proxy_cache_bypass 1;
|
proxy_cache_bypass 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
|
||||||
location ^~ /websocket/ {
|
location ^~ /websocket/ {
|
||||||
proxy_pass http://127.0.0.1:8083/;
|
proxy_pass http://127.0.0.1:8083/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -80,24 +130,4 @@ server {
|
|||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /mcp {
|
}
|
||||||
proxy_pass http://127.0.0.1:8086;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user