Merge remote-tracking branch 'origin/main' into feat_category_proposal

This commit is contained in:
Tim
2025-10-22 19:54:17 +08:00
86 changed files with 2806 additions and 747 deletions

118
.env.example Normal file
View File

@@ -0,0 +1,118 @@
# === Core Service Ports ===
SERVER_PORT=8080
FRONTEND_PORT=3000
WEBSOCKET_PORT=8082
MYSQL_PORT=3306
REDIS_PORT=6379
RABBITMQ_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672
# === OpenSearch Configuration ===
OPENSEARCH_PORT=9200
OPENSEARCH_METRICS_PORT=9600
OPENSEARCH_DASHBOARDS_PORT=5601
OPENSEARCH_ENABLED=true
OPENSEARCH_SCHEME=http
OPENSEARCH_USERNAME=
OPENSEARCH_PASSWORD=
OPENSEARCH_HOST=opensearch
# === Database Configuration ===
MYSQL_DATABASE=openisle
MYSQL_ROOT_PASSWORD=openisle
MYSQL_USER=openisle
MYSQL_PASSWORD=openisle
MYSQL_HOST=mysql
# === Redis Configuration ===
REDIS_HOST=redis
REDIS_DATABASE=0
# === RabbitMQ Configuration ===
RABBITMQ_HOST=rabbitmq
RABBITMQ_USERNAME=nagisa
RABBITMQ_PASSWORD=nagisa
# === Backend Application Secrets ===
JWT_SECRET=change-me-jwt-secret
JWT_REASON_SECRET=change-me-jwt-reason-secret
JWT_RESET_SECRET=change-me-jwt-reset-secret
JWT_INVITE_SECRET=change-me-jwt-invite-secret
JWT_EXPIRATION=2592000000
PASSWORD_STRENGTH=LOW
POST_PUBLISH_MODE=DIRECT
REGISTER_MODE=WHITELIST
UPLOAD_CHECK_TYPE=true
UPLOAD_MAX_SIZE=5242880
AVATAR_STYLE=pixel-art-neutral
AVATAR_SIZE=128
AVATAR_BASE_URL=https://api.dicebear.com/6.x
USER_POSTS_LIMIT=10
USER_REPLIES_LIMIT=50
SNIPPET_LENGTH=200
SEARCH_INDEX_PREFIX=openisle
SEARCH_HIGHLIGHT_FRAGMENT_SIZE=200
SEARCH_REINDEX_ON_STARTUP=true
SEARCH_REINDEX_BATCH_SIZE=500
CAPTCHA_ENABLED=false
RECAPTCHA_SECRET_KEY=
CAPTCHA_REGISTER_ENABLED=false
CAPTCHA_LOGIN_ENABLED=false
CAPTCHA_POST_ENABLED=false
CAPTCHA_COMMENT_ENABLED=false
RESEND_API_KEY=
RESEND_FROM_EMAIL=
COS_BASE_URL=https://<你的cos>.cos.accelerate.myqcloud.com
COS_SECRET_ID=
COS_SECRET_KEY=
COS_REGION=ap-guangzhou
COS_BUCKET_NAME=
GITHUB_CLIENT_SECRET=
DISCORD_CLIENT_SECRET=
TWITTER_CLIENT_SECRET=
TELEGRAM_BOT_TOKEN=
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
AI_FORMAT_LIMIT=3
WEBSITE_URL=http://localhost:3000
WEBPUSH_PUBLIC_KEY=
WEBPUSH_PRIVATE_KEY=
LOG_LEVEL=INFO
# === Frontend (Nuxt) ===
# 本地开发
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
# 线上环境
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
# 测试环境
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
# 本地开发
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
# 线上环境
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
# 测试环境
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket
# 本地开发
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
# 线上
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
# 测试环境 (www.staging.open-isle.com)
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM
# 本地
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
# 线上 & 本地均可使用
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
# 线上 & 本地均可使用
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
# 线上
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 测试环境 (www.staging.open-isle.com)
# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011

View File

@@ -11,12 +11,17 @@ on:
permissions:
contents: write
# 文档发布自己的排队锁,不影响服务器部署
concurrency:
group: openisle-docs
cancel-in-progress: false
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -2,28 +2,33 @@ name: Staging CI & CD
on:
push:
branches: [main]
branches: [ "main" ]
workflow_dispatch:
permissions:
contents: write
# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥)
concurrency:
group: openisle-server
cancel-in-progress: false
jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment: Deploy
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
if: ${{ !github.event.repository.fork }}
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
- name: Deploy to Server (staging)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy-staging.sh
script: bash /opt/openisle/OpenIsle/deploy/deploy_staging.sh
deploy-docs:
needs: build-and-deploy

View File

@@ -2,8 +2,13 @@ name: CI & CD
on:
workflow_dispatch:
# schedule:
# - cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00
# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑
concurrency:
group: openisle-server
cancel-in-progress: false
jobs:
build-and-deploy:
@@ -13,10 +18,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Deploy to Server
- name: Deploy to Server (prod)
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: bash /opt/openisle/deploy.sh
script: bash /opt/openisle/OpenIsle/deploy/deploy.sh

View File

@@ -1,25 +1,19 @@
- [前置工作](#前置工作)
- [前端极速调试Docker 全量环境)](#前端极速调试docker-全量环境)
- [启动后端服务](#启动后端服务)
- [本地 IDEA](#本地-idea)
- [配置环境变量](#配置环境变量)
- [配置 IDEA 参数](#配置-idea-参数)
- [配置 MySQL](#配置-mysql)
- [配置 Redis](#配置-redis)
- [配置 RabbitMQ](#配置-rabbitmq)
- [Docker 环境](#docker-环境)
- [配置环境变量](#配置环境变量-1)
- [构建并启动镜像](#构建并启动镜像)
- [启动前端服务](#启动前端服务)
- [配置环境变量](#配置环境变量-2)
- [安装依赖和运行](#安装依赖和运行)
- [连接预发或正式环境](#连接预发或正式环境)
- [其他配置](#其他配置)
- [配置第三方登录以GitHub为例](#配置第三方登录以GitHub为例)
- [配置Resend邮箱服务](#配置Resend邮箱服务)
- [配置第三方登录以GitHub为例](#配置第三方登录以github为例)
- [配置Resend邮箱服务](#配置resend邮箱服务)
- [API文档](#api文档)
- [OpenAPI文档](#openapi文档)
- [部署时间线以及文档时效性](#部署时间线以及文档时效性)
- [OpenAPI文档使用](#OpenAPI文档使用)
- [OpenAPI文档应用场景](#OpenAPI文档应用场景)
- [OpenAPI文档使用](#openapi文档使用)
- [OpenAPI文档应用场景](#openapi文档应用场景)
## 前置工作
@@ -35,6 +29,58 @@ cd OpenIsle
- 前端开发环境
- Node.JS 20+
## 前端极速调试Docker 全量环境)
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server适合需要全链路联调的场景。
1. 准备环境变量文件:
```shell
cp .env.example .env
```
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
2. 启动 Dev Profile
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev build
```
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev up -d
```
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
修改前端代码,页面会热更新。
如果修改后端代码,可以重启后端容器, 或是环境变量中指向IDEA采用IDEA编译运行也可以哦。
```shell
docker compose \
-f docker/docker-compose.yaml \
--env-file .env \
--profile dev up -d --force-recreate
```
3. 查看服务状态:
```shell
docker compose -f docker/docker-compose.yaml --env-file .env ps
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
```
4. 停止所有容器:
```shell
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` 中各卷的定义进行调整。
## 启动后端服务
启动后端服务有多种方式,选择一种即可。
@@ -52,37 +98,26 @@ IDEA 打开 `backend/` 文件夹。
#### 配置环境变量
1. 生成环境变量文件
1. 生成环境变量文件
```shell
cp open-isle.env.example open-isle.env
```
`open-isle.env` 才是实际被读取的文件。可在其中补充数据库、第三方服务等配置,`open-isle.env` 已被 Git 忽略,放心修改。
2. 在 IDEA 中配置「Environment file」将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `open-isle.env`,即可让 IDE 读取该文件。
3. 需要调整端口或功能开关时,优先修改 `open-isle.env`,例如:
```ini
SERVER_PORT=8081
LOG_LEVEL=DEBUG
```
`open-isle.env.example` 是环境变量模板,`open-isle.env` 才是真正读取的内容
2. 修改环境变量,留下需要的,比如你要开发 Google 登录业务,就需要谷歌相关的变量,数据库是一定要的
![环境变量](assets/contributing/backend_img_7.png)
3. 应用环境文件,选择刚刚的 `open-isle.env`
可以在 `open-isle.env` 按需填写个性化的配置,该文件不会被 Git 追踪。比如你想把服务跑在 `8082`(默认为 `8080`),那么直接改 `open-isle.env` 即可:
```ini
SERVER_PORT=8082
```
另一种方式是修改 `.properities` 文件(但不建议),位于 `src/main/application.properties`,该配置同样来源于 `open-isle.env`,但修改 `.properties` 文件会被 Git 追踪。
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
![配置数据库](assets/contributing/backend_img_5.png)
#### 配置 IDEA 参数
- 设置 JDK 版本为 java 17
- 设置 VM Option最好运行在其他端口非 `8080`,这里设置 `8081`
若上面在环境变量中设置了端口,那这里就不需要再额外设置
- 设置 JDK 版本为 Java 17
- 设置 VM Option最好运行在其他端口例如 `8081`)。若已经在 `open-isle.env` 中调整端口,可省略此步骤。
```shell
-Dserver.port=8081
```
@@ -91,191 +126,22 @@ SERVER_PORT=8082
![配置2](assets/contributing/backend_img_2.png)
#### 配置 MySQL
> [!TIP]
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
1. 本机配置 MySQL 服务(网上很多教程,忽略)
- 可以用 Laragon自带 MySQL 包括 Nodejs版本建议 `6.x``7` 以后需要 Lisence
- [下载地址](https://github.com/leokhoa/laragon/releases)
2. 填写环境变量
![环境变量](assets/contributing/backend_img_6.png)
```ini
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>
```
3. 执行 [`db/init/init_script.sql`](backend/src/main/resources/db/init/init_script.sql) 脚本,导入基本的数据
管理员:**admin/123456**
普通用户1**user1/123456**
普通用户2**user2/123456**
![初始化脚本](assets/contributing/resources_img.png)
#### 配置 Redis
后端的登录态缓存、访问频控等都依赖 Redis请确保本地有可用的 Redis 实例。
1. **启动 Redis 服务**(已有服务可跳过)
```bash
docker run --name openisle-redis -p 6379:6379 -d redis:7-alpine
```
该命令会在本机暴露 `6379` 端口。若你已有其他端口的 Redis可以根据实际情况调整映射关系。
2. **在 `backend/open-isle.env` 中填写连接信息**
```ini
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# 可选:若需要切换逻辑库,可新增此变量,默认使用 0 号库
REDIS_DATABASE=0
```
`application.properties` 中的默认值为 `localhost:6379`、数据库 `0`,如果你的环境恰好一致,也可以不额外填写;显式声明可以避免 IDE/运行时读取到意外配置。
3. **验证连接**
```bash
redis-cli -h 127.0.0.1 -p 6379 ping
```
启动后端后,日志中会出现 `Redis connection established ...`(来自 `RedisConnectionLogger`),说明已成功连通。
#### 配置 RabbitMQ
消息通知和 WebSocket 推送链路依赖 RabbitMQ。后端会自动声明交换机与队列确保本地 RabbitMQ 可用即可。
1. **启动 RabbitMQ 服务**(推荐包含管理界面)
```bash
docker run --name openisle-rabbitmq \
-e RABBITMQ_DEFAULT_USER=openisle \
-e RABBITMQ_DEFAULT_PASS=openisle \
-p 5672:5672 -p 15672:15672 \
-d rabbitmq:3.13-management
```
管理界面位于 http://127.0.0.1:15672 ,可用于查看队列、交换机等资源。
2. **同步填写后端与 WebSocket 服务的环境变量**
```ini
# backend/open-isle.env
RABBITMQ_HOST=127.0.0.1
RABBITMQ_PORT=5672
RABBITMQ_USERNAME=openisle
RABBITMQ_PASSWORD=openisle
# 如果需要启动 websocket_service也需要在 websocket_service.env 中保持一致
```
如果沿用 RabbitMQ 默认的 `guest/guest`可以不显式设置Spring Boot 会回退到 `application.properties` 中的默认值 (`localhost:5672`、`guest/guest`、虚拟主机 `/`)。
3. **确认自动声明的资源**
- 交换机:`openisle-exchange`
- 旧版兼容队列:`notifications-queue`
- 分片队列:`notifications-queue-0` ~ `notifications-queue-f`(共 16 个,对应路由键 `notifications.shard.0` ~ `notifications.shard.f`
- 队列持久化默认开启,来自 `rabbitmq.queue.durable=true`,如需仅在本地短暂测试,可在 `application.properties` 中调整该配置。
启动后端时可在日志中看到 `=== 开始主动声明 RabbitMQ 组件 ===` 与后续的声明结果,也可以在管理界面中查看是否创建成功。
完成 Redis 与 RabbitMQ 配置后,即可继续启动后端服务。
完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。
![运行画面](assets/contributing/backend_img_4.png)
### Docker 环境
## 前端连接预发或正式环境
#### 配置环境变量
前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境:
```shell
cd docker/
```
1. 按需覆盖关键变量:
主要配置两个 `.env` 文件
```ini
NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
```
将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等可根据需求调整。
- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。
- `docker/.env`Docker Compose 环境变量,主要配置 MySQL 相关
```shell
cp .env.example .env
```
> [!TIP]
> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
在指定 `docker/.env` 后,`backend/open-isle.env` 中以下配置会被覆盖,这样就确保使用了同一份配置。
```ini
MYSQL_URL=
MYSQL_USER=
MYSQL_PASSWORD=
```
#### 构建并启动镜像
```shell
docker compose up -d
```
如果想了解启动过程发生了什么可以查看日志
```shell
docker compose logs
```
## 启动前端服务
> [!IMPORTANT]
> **⚠️ 环境要求Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
```shell
cd frontend_nuxt/
```
### 配置环境变量
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
```shell
cp .env.staging.example .env
```
- 利用生产环境
```shell
cp .env.production.example .env
```
- 利用本地环境
```shell
cp .env.dev.example .env
```
若依赖本机部署的后端,需要修改 `.env` 中的 `NUXT_PUBLIC_API_BASE_URL` 值与后端服务端口一致
### 安装依赖和运行
前端安装依赖并启动服务。
```shell
# 安装依赖
npm install --verbose
# 运行前端服务
npm run dev
```
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。
## 其他配置
@@ -334,7 +200,7 @@ https://docs.open-isle.com
### OpenAPI文档使用
- 预发环境/正式环境切换,以通过如下位置切换API环境
- 预发环境/正式环境切换以通过如下位置切换API环境
![CleanShot 2025-09-10 at 12.08.00@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f9fb7a0f020d4a0e94159d7820783224.png)

176
SECURITY.md Normal file
View File

@@ -0,0 +1,176 @@
# Security Policy
## Supported Versions
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
| Version | Supported |
| ------- | ------------------ |
| 0.0.x | :white_check_mark: |
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
### How to Report a Security Vulnerability
**Please do not report security vulnerabilities through public GitHub issues.**
Instead, please report them via one of the following methods:
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
### What to Include in Your Report
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
- Full paths of source file(s) related to the manifestation of the issue
- The location of the affected source code (tag/branch/commit or direct URL)
- Any special configuration required to reproduce the issue
- Step-by-step instructions to reproduce the issue
- Proof-of-concept or exploit code (if possible)
- Impact of the issue, including how an attacker might exploit it
### Response Timeline
- **Initial Response**: We will acknowledge your report within 48 hours
- **Status Updates**: We will provide status updates at least every 5 business days
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
### What to Expect
After you submit a report:
1. We will confirm receipt of your vulnerability report and may ask for additional information
2. We will investigate the issue and determine its impact and severity
3. We will work on a fix and coordinate disclosure timing with you
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
## Security Considerations for Deployment
### Authentication & Authorization
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
- **Session Management**: Configure appropriate session timeout values
### Database Security
- Use strong database passwords
- Never expose database ports publicly
- Use database connection encryption when available
- Regularly backup your database
### API Security
- Enable rate limiting to prevent abuse
- Validate all user inputs on both client and server side
- Use HTTPS in production environments
- Configure CORS properly to restrict origins
### Environment Variables
The following sensitive environment variables should be kept secure:
- `JWT_SECRET` - JWT signing key
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
- Database connection strings and credentials
- Cloud storage credentials (Tencent COS)
**Never commit these values to version control or expose them in logs.**
### File Upload Security
- Validate file types and sizes
- Scan uploaded files for malware
- Store uploaded files outside the web root
- Use cloud storage with proper access controls
### Password Security
- Configure password strength requirements via environment variables
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
- Implement account lockout after failed login attempts
### Web Push Notifications
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
- Only send notifications to users who have explicitly opted in
- Validate notification payloads
### Dependency Management
- Regularly update dependencies to patch known vulnerabilities
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
- Monitor GitHub security advisories for this project
### Production Deployment Checklist
- [ ] Use HTTPS/TLS for all connections
- [ ] Set strong, unique secrets for all environment variables
- [ ] Enable CSRF protection
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
- [ ] Disable debug mode and verbose error messages
- [ ] Set up proper logging and monitoring
- [ ] Implement rate limiting and DDoS protection
- [ ] Regular security updates and patches
- [ ] Database backups and disaster recovery plan
- [ ] Restrict admin access to trusted IPs when possible
## Known Security Features
OpenIsle includes the following security features:
- JWT-based authentication with configurable expiration
- OAuth 2.0 integration with major providers
- Password strength validation
- Protection codes for sensitive operations
- Input validation and sanitization
- SQL injection prevention through ORM (JPA/Hibernate)
- XSS protection in Vue.js templates
- CSRF protection (Spring Security)
## Security Best Practices for Contributors
- Never commit credentials, API keys, or secrets
- Follow secure coding practices (OWASP Top 10)
- Validate and sanitize all user inputs
- Use parameterized queries for database operations
- Implement proper error handling without exposing sensitive information
- Write security tests for new features
- Review code for security issues before submitting PRs
## Disclosure Policy
When we receive a security bug report, we will:
1. Confirm the problem and determine affected versions
2. Audit code to find any similar problems
3. Prepare fixes for all supported versions
4. Release patches as soon as possible
We appreciate your help in keeping OpenIsle and its users safe!
## Attribution
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
- Security advisory
- Release notes
- A security hall of fame (if established)
If you prefer to remain anonymous, we will respect your wishes.
## Contact
For any security-related questions or concerns, please reach out through the channels mentioned above.
---
Thank you for helping keep OpenIsle secure!

View File

@@ -1,3 +1,6 @@
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
# 此文件保留作参考用途,如需在 Docker 之外手动配置,可按需复制。
# === Spring Boot ===
SERVER_PORT=8080

View File

@@ -132,6 +132,10 @@
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 高阶 Java 客户端 -->
<dependency>
<groupId>org.opensearch.client</groupId>

View File

@@ -97,6 +97,8 @@ public class SecurityConfig {
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://frontend_dev:3000",
"http://frontend_service:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
@@ -177,6 +179,8 @@ public class SecurityConfig {
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods")
.permitAll()
.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**")
@@ -230,6 +234,7 @@ public class SecurityConfig {
uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") ||
uri.startsWith("/api/medals") ||
uri.startsWith("/actuator") ||
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {

View File

@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -131,6 +132,7 @@ public class CommentController {
c.getId(),
"comment",
c.getCreatedAt(),
c.getPinnedAt(),
c // payload 是 CommentDto
)
)
@@ -145,17 +147,39 @@ public class CommentController {
l.getId(),
"log",
l.getTime(), // 注意字段名不一样
null,
l // payload 是 PostChangeLogDto
)
)
.toList()
);
// 排序
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
Comparator<TimelineItemDto<?>> pinnedOrderComparator = (a, b) -> {
LocalDateTime aPinned = a.getPinnedAt();
LocalDateTime bPinned = b.getPinnedAt();
if (aPinned == null && bPinned == null) {
return 0;
}
if (aPinned == null) {
return 1;
}
if (bPinned == null) {
return -1;
}
return bPinned.compareTo(aPinned);
};
Comparator<TimelineItemDto<?>> comparator = Comparator.<TimelineItemDto<?>, Boolean>comparing(
item -> item.getPinnedAt() == null
).thenComparing(pinnedOrderComparator);
Comparator<TimelineItemDto<?>> createdAtComparator = Comparator.comparing(
TimelineItemDto::getCreatedAt
);
if (CommentSort.NEWEST.equals(sort)) {
comparator = comparator.reversed();
createdAtComparator = createdAtComparator.reversed();
}
itemDtoList.sort(comparator);
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
log.debug("listComments returning {} comments", itemDtoList.size());
return itemDtoList;
}

View File

@@ -0,0 +1,43 @@
package com.openisle.controller;
import com.openisle.dto.DonationRequest;
import com.openisle.dto.DonationResponse;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts/{postId}/donations")
@RequiredArgsConstructor
public class PostDonationController {
private final PointService pointService;
@GetMapping
@Operation(summary = "List donations", description = "Get recent donations for a post")
@ApiResponse(responseCode = "200", description = "Donation summary")
public DonationResponse list(@PathVariable Long postId) {
return pointService.getPostDonations(postId);
}
@PostMapping
@SecurityRequirement(name = "JWT")
@Operation(summary = "Donate", description = "Donate points to the post author")
@ApiResponse(responseCode = "200", description = "Donation result")
public DonationResponse donate(
@PathVariable Long postId,
@RequestBody DonationRequest req,
Authentication auth
) {
return pointService.donateToPost(auth.getName(), postId, req.getAmount());
}
}

View File

@@ -0,0 +1,16 @@
package com.openisle.dto;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationDto {
private Long userId;
private String username;
private String avatar;
private int amount;
private LocalDateTime createdAt;
}

View File

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

View File

@@ -0,0 +1,15 @@
package com.openisle.dto;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class DonationResponse {
private int totalAmount;
private List<DonationDto> donations = new ArrayList<>();
private Integer balance;
}

View File

@@ -29,4 +29,5 @@ public class PostChangeLogDto {
private LocalDateTime newPinnedAt;
private Boolean oldFeatured;
private Boolean newFeatured;
private Integer amount;
}

View File

@@ -15,5 +15,6 @@ public class TimelineItemDto<T> {
private Long id;
private String kind; // "comment" | "log"
private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private T payload; // 泛型,具体类型由外部决定
}

View File

@@ -52,6 +52,8 @@ public class PostChangeLogMapper {
} else if (log instanceof PostFeaturedChangeLog f) {
dto.setOldFeatured(f.isOldFeatured());
dto.setNewFeatured(f.isNewFeatured());
} else if (log instanceof PostDonateChangeLog d) {
dto.setAmount(d.getAmount());
}
return dto;
}

View File

@@ -48,6 +48,8 @@ public enum NotificationType {
POLL_RESULT_PARTICIPANT,
/** Your post was featured */
POST_FEATURED,
/** Someone donated to your post */
DONATION,
/** You were mentioned in a post or comment */
MENTION,
}

View File

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

View File

@@ -10,4 +10,5 @@ public enum PostChangeType {
FEATURED,
VOTE_RESULT,
LOTTERY_RESULT,
DONATE,
}

View File

@@ -0,0 +1,19 @@
package com.openisle.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
@Entity
@Table(name = "post_donate_change_logs")
public class PostDonateChangeLog extends PostChangeLog {
@Column(nullable = false)
private int amount;
}

View File

@@ -2,11 +2,14 @@ package com.openisle.repository;
import com.openisle.model.Comment;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.Post;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
@@ -21,4 +24,11 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
List<PointHistory> findByComment(Comment comment);
List<PointHistory> findByPost(Post post);
List<PointHistory> findTop10ByPostAndTypeOrderByCreatedAtDesc(Post post, PointHistoryType type);
@Query(
"SELECT COALESCE(SUM(ph.amount), 0) FROM PointHistory ph WHERE ph.post = :post AND ph.type = :type"
)
Long sumAmountByPostAndType(@Param("post") Post post, @Param("type") PointHistoryType type);
}

View File

@@ -1,5 +1,7 @@
package com.openisle.service;
import com.openisle.dto.DonationDto;
import com.openisle.dto.DonationResponse;
import com.openisle.exception.FieldException;
import com.openisle.model.*;
import com.openisle.repository.*;
@@ -8,8 +10,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -20,6 +24,8 @@ public class PointService {
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
private final NotificationService notificationService;
private final PostChangeLogService postChangeLogService;
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
@@ -272,4 +278,95 @@ public class PointService {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
@Transactional
public DonationResponse donateToPost(String donorName, Long postId, int amount) {
if (amount <= 0) {
throw new FieldException("amount", "打赏积分必须大于0");
}
User donor = userRepository.findByUsername(donorName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
User author = post.getAuthor();
if (author.getId().equals(donor.getId())) {
throw new FieldException("post", "不能给自己打赏");
}
if (donor.getPoint() < amount) {
throw new FieldException("point", "积分不足");
}
addPoint(donor, -amount, PointHistoryType.DONATE_SENT, post, null, author);
addPoint(author, amount, PointHistoryType.DONATE_RECEIVED, post, null, donor);
notificationService.createNotification(
author,
NotificationType.DONATION,
post,
null,
null,
donor,
null,
String.valueOf(amount)
);
postChangeLogService.recordDonation(post, donor, amount);
DonationResponse response = buildDonationResponse(post);
response.setBalance(donor.getPoint());
return response;
}
public DonationResponse getPostDonations(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
return buildDonationResponse(post);
}
private DonationResponse buildDonationResponse(Post post) {
List<PointHistory> histories =
pointHistoryRepository.findTop10ByPostAndTypeOrderByCreatedAtDesc(
post,
PointHistoryType.DONATE_RECEIVED
);
List<DonationDto> donations = histories
.stream()
.collect(Collectors.collectingAndThen(Collectors.toMap(
history -> {
User donor = history.getFromUser();
if (donor != null && donor.getId() != null) {
return "user:" + donor.getId();
}
return "history:" + history.getId();
},
history -> {
DonationDto dto = new DonationDto();
User donor = history.getFromUser();
if (donor != null) {
dto.setUserId(donor.getId());
dto.setUsername(donor.getUsername());
dto.setAvatar(donor.getAvatar());
}
dto.setAmount(history.getAmount());
dto.setCreatedAt(history.getCreatedAt());
return dto;
},
(left, right) -> {
left.setAmount(left.getAmount() + right.getAmount());
if (
left.getCreatedAt() == null ||
(right.getCreatedAt() != null && right.getCreatedAt().isAfter(left.getCreatedAt()))
) {
left.setCreatedAt(right.getCreatedAt());
}
return left;
},
java.util.LinkedHashMap::new
), map -> new java.util.ArrayList<>(map.values())));
Long total = pointHistoryRepository.sumAmountByPostAndType(
post,
PointHistoryType.DONATE_RECEIVED
);
int safeTotal = 0;
if (total != null) {
safeTotal = total > Integer.MAX_VALUE ? Integer.MAX_VALUE : total.intValue();
}
DonationResponse response = new DonationResponse();
response.setDonations(donations);
response.setTotalAmount(safeTotal);
return response;
}
}

View File

@@ -115,6 +115,15 @@ public class PostChangeLogService {
logRepository.save(log);
}
public void recordDonation(Post post, User donor, int amount) {
PostDonateChangeLog log = new PostDonateChangeLog();
log.setPost(post);
log.setUser(donor);
log.setType(PostChangeType.DONATE);
log.setAmount(amount);
logRepository.save(log);
}
public void deleteLogsForPost(Post post) {
logRepository.deleteByPost(post);
}

View File

@@ -4,7 +4,7 @@ server.port=${SERVER_PORT:8080}
# for mysql
logging.level.root=${LOG_LEVEL:INFO}
logging.level.com.openisle.service.CosImageUploader=DEBUG
spring.datasource.url=${MYSQL_URL:jdbc:mysql://localhost:3306/openisle}
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=${MYSQL_USER:root}
spring.datasource.password=${MYSQL_PASSWORD:password}
spring.jpa.hibernate.ddl-auto=update
@@ -48,11 +48,11 @@ app.snippet-length=${SNIPPET_LENGTH:200}
# OpenSearch integration
app.search.enabled=${SEARCH_ENABLED:true}
app.search.host=${SEARCH_HOST:localhost}
app.search.port=${SEARCH_PORT:9200}
app.search.scheme=${SEARCH_SCHEME:http}
app.search.username=${SEARCH_USERNAME:}
app.search.password=${SEARCH_PASSWORD:}
app.search.host=${OPENSEARCH_HOST:opensearch}
app.search.port=${OPENSEARCH_PORT:9200}
app.search.scheme=${OPENSEARCH_SCHEME:http}
app.search.username=${OPENSEARCH_USERNAME:}
app.search.password=${OPENSEARCH_PASSWORD:}
app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle}
app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}}
app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true}
@@ -82,15 +82,15 @@ cos.bucket-name=${COS_BUCKET_NAME:}
# your image upload services: ...
# Google OAuth configuration
google.client-id=${GOOGLE_CLIENT_ID:}
google.client-id=${NUXT_PUBLIC_GOOGLE_CLIENT_ID:}
# GitHub OAuth configuration
github.client-id=${GITHUB_CLIENT_ID:}
github.client-id=${NUXT_PUBLIC_GITHUB_CLIENT_ID:}
github.client-secret=${GITHUB_CLIENT_SECRET:}
# Discord OAuth configuration
discord.client-id=${DISCORD_CLIENT_ID:}
discord.client-id=${NUXT_PUBLIC_DISCORD_CLIENT_ID:}
discord.client-secret=${DISCORD_CLIENT_SECRET:}
# Twitter OAuth configuration
twitter.client-id=${TWITTER_CLIENT_ID:}
twitter.client-id=${NUXT_PUBLIC_TWITTER_CLIENT_ID:}
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
# Telegram login configuration
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
@@ -130,3 +130,6 @@ springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true

View File

@@ -0,0 +1,13 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
SET collation_connection = utf8mb4_0900_ai_ci;
CREATE DATABASE IF NOT EXISTS `openisle`
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
CREATE USER IF NOT EXISTS 'openisle'@'%' IDENTIFIED BY 'openisle';
GRANT ALL PRIVILEGES ON `openisle`.* TO 'openisle'@'%';
FLUSH PRIVILEGES;
USE `openisle`;

View File

@@ -0,0 +1,54 @@
USE `openisle`;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`display_medal` varchar(255) DEFAULT NULL,
`email` varchar(255) NOT NULL,
`experience` int DEFAULT NULL,
`introduction` text,
`password` varchar(255) NOT NULL,
`password_reset_code` varchar(255) DEFAULT NULL,
`point` int DEFAULT NULL,
`register_reason` text,
`role` varchar(20) DEFAULT 'USER',
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `categories` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_categories_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `tags` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
`creator_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_tags_name` (`name`),
KEY `FK_tags_creator` (`creator_id`),
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -0,0 +1,26 @@
USE `openisle`;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DELETE FROM `tags`;
DELETE FROM `categories`;
DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123456
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 110, '测试测试测试……', 'USER', 'user1', NULL, b'1'),
(3, b'1', 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$x7HXjUyJTmrvqjnBlBQZH.vmfsC56NzTSWqQ6WqZqRjUO859EhviS', NULL, 40, '测试测试测试……', 'USER', 'user2', NULL, b'1');
INSERT INTO `categories` (`id`,`description`,`icon`,`name`,`small_icon`) VALUES
(1,'测试用分类1','star','测试用分类1',NULL),
(2,'测试用分类2','star','测试用分类2',NULL),
(3,'测试用分类3','star','测试用分类3',NULL);
INSERT INTO `tags` (`id`,`approved`,`created_at`,`description`,`icon`,`name`,`small_icon`,`creator_id`) VALUES
(1,b'1','2025-09-02 10:51:56.000000','测试用标签1',NULL,'测试用标签1',NULL,NULL),
(2,b'1','2025-09-02 10:51:56.000000','测试用标签2',NULL,'测试用标签2',NULL,NULL),
(3,b'1','2025-09-02 10:51:56.000000','测试用标签3',NULL,'测试用标签3',NULL,NULL);
SET FOREIGN_KEY_CHECKS = 1;

View File

@@ -1,81 +0,0 @@
-- 2025-09-02
-- 本地化开发,初始化脚本
-- 抽奖的时候奖品图片是必须的把相关代码注释掉即可跳过check
-- 设置字符集和排序规则
SET NAMES utf8;
SET CHARACTER SET utf8;
SET collation_connection = utf8_general_ci;
-- 创建 users 表(如果不存在)
CREATE TABLE IF NOT EXISTS `users` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`display_medal` varchar(255) DEFAULT NULL,
`email` varchar(255) NOT NULL,
`experience` int DEFAULT NULL,
`introduction` text,
`password` varchar(255) NOT NULL,
`password_reset_code` varchar(255) DEFAULT NULL,
`point` int DEFAULT NULL,
`register_reason` text,
`role` varchar(20) DEFAULT 'USER',
`username` varchar(50) NOT NULL,
`verification_code` varchar(255) DEFAULT NULL,
`verified` bit(1) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_users_email` (`email`),
UNIQUE KEY `UK_users_username` (`username`)
);
-- 清空users表
DELETE FROM `users`;
-- 插入用户,两个普通用户,一个管理员
-- username:admin/user1/user2 password:123321
INSERT INTO `users` (`id`, `approved`, `avatar`, `created_at`, `display_medal`, `email`, `experience`, `introduction`, `password`, `password_reset_code`, `point`, `register_reason`, `role`, `username`, `verification_code`, `verified`) VALUES
(1, b'1', '', '2025-09-01 16:08:17.426430', 'PIONEER', 'adminmail@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'ADMIN', 'admin', NULL, b'1'),
(2, b'1', '', '2025-09-03 16:08:17.426430', 'PIONEER', 'usermail2@openisle.com', 70, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 110, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user1', NULL, b'1'),
(3, b'1', '', '2025-09-02 17:21:21.617666', 'PIONEER', 'usermail1@openisle.com', 40, NULL, '$2a$10$dux.NXwW09cCsdZ05BgcnOtxVqqjcmnbj3.8xcxGl/iiIlv06y7Oe', NULL, 40, '测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试测试', 'USER', 'user2', NULL, b'1');
-- 创建 tags 表(如果不存在)
CREATE TABLE IF NOT EXISTS `tags` (
`id` bigint NOT NULL AUTO_INCREMENT,
`approved` bit(1) DEFAULT NULL,
`created_at` datetime(6) DEFAULT NULL,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
`creator_id` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_tags_name` (`name`),
KEY `FK_tags_creator` (`creator_id`),
CONSTRAINT `FK_tags_creator` FOREIGN KEY (`creator_id`) REFERENCES `users` (`id`)
);
-- 清空tags表
DELETE FROM `tags`;
-- 插入标签,三个测试用标签
INSERT INTO `tags` (`id`, `approved`, `created_at`, `description`, `icon`, `name`, `small_icon`, `creator_id`) VALUES
(1, b'1', '2025-09-02 10:51:56.000000', '测试用标签1', NULL, '测试用标签1', NULL, NULL),
(2, b'1', '2025-09-02 10:51:56.000000', '测试用标签2', NULL, '测试用标签2', NULL, NULL),
(3, b'1', '2025-09-02 10:51:56.000000', '测试用标签3', NULL, '测试用标签3', NULL, NULL);
-- 创建 categories 表(如果不存在)
CREATE TABLE IF NOT EXISTS `categories` (
`id` bigint NOT NULL AUTO_INCREMENT,
`description` text,
`icon` varchar(255) DEFAULT NULL,
`name` varchar(50) NOT NULL,
`small_icon` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `UK_categories_name` (`name`)
);
-- 清空categories表
DELETE FROM `categories`;
-- 插入分类,三个测试用分类
INSERT INTO `categories` (`id`, `description`, `icon`, `name`, `small_icon`) VALUES
(1, '测试用分类1', '1', '测试用分类1', NULL),
(2, '测试用分类2', '2', '测试用分类2', NULL),
(3, '测试用分类3', '3', '测试用分类3', NULL);

56
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
# 可用法:
# ./deploy.sh
# ./deploy.sh feature/docker
deploy_branch="${1:-main}"
repo_dir="/opt/openisle/OpenIsle"
compose_file="${repo_dir}/docker/docker-compose.yaml"
env_file="${repo_dir}/.env"
project="openisle"
echo "👉 Enter repo..."
cd "$repo_dir"
echo "👉 Syncing code & switching to branch: $deploy_branch"
git fetch --all --prune
git checkout -B "$deploy_branch" "origin/$deploy_branch"
git reset --hard "origin/$deploy_branch"
echo "👉 Ensuring env file: $env_file"
if [ ! -f "$env_file" ]; then
echo "${env_file} not found. Create it based on .env.example (with domains)."
exit 1
fi
export COMPOSE_PROJECT_NAME="$project"
# 供 compose 内各 service 的 env_file 使用
export ENV_FILE="$env_file"
echo "👉 Validate compose..."
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images ..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=production \
frontend_service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps
echo "👉 Pruning dangling images..."
docker image prune -f
echo "✅ Stack deployed at $(date)"

56
deploy/deploy_staging.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
# 可用法:
# ./deploy-staging.sh
# ./deploy-staging.sh feature/docker
deploy_branch="${1:-main}"
repo_dir="/opt/openisle/OpenIsle-staging"
compose_file="${repo_dir}/docker/docker-compose.yaml"
env_file="${repo_dir}/.env"
project="openisle_staging"
echo "👉 Enter repo..."
cd "$repo_dir"
echo "👉 Syncing code & switching to branch: $deploy_branch"
git fetch --all --prune
git checkout -B "$deploy_branch" "origin/$deploy_branch"
git reset --hard "origin/$deploy_branch"
echo "👉 Ensuring env file: $env_file"
if [ ! -f "$env_file" ]; then
echo "${env_file} not found. Create it based on .env.example (with staging domains)."
exit 1
fi
export COMPOSE_PROJECT_NAME="$project"
# 供 compose 内各 service 的 env_file 使用
export ENV_FILE="$env_file"
echo "👉 Validate compose..."
docker compose -f "$compose_file" --env-file "$env_file" config >/dev/null
echo "👉 Pull base images (for image-based services)..."
docker compose -f "$compose_file" --env-file "$env_file" pull --ignore-pull-failures
echo "👉 Build images (staging)..."
# 前端 + OpenSearch 都是自建镜像;--pull 更新其基础镜像
docker compose -f "$compose_file" --env-file "$env_file" \
build --pull \
--build-arg NUXT_ENV=staging \
frontend_service
echo "👉 Recreate & start all target services (no dev profile)..."
docker compose -f "$compose_file" --env-file "$env_file" \
up -d --force-recreate --remove-orphans --no-deps \
mysql redis rabbitmq websocket-service springboot frontend_service
echo "👉 Current status:"
docker compose -f "$compose_file" --env-file "$env_file" ps
echo "👉 Pruning dangling images..."
docker image prune -f
echo "✅ Staging stack deployed at $(date)"

View File

@@ -1,16 +1,4 @@
# 前端访问端口
SERVER_PORT=8080
# OpenSearch 配置
OPENSEARCH_PORT=9200
OPENSEARCH_METRICS_PORT=9600
OPENSEARCH_DASHBOARDS_PORT=5601
# MySQL 配置
MYSQL_ROOT_PASSWORD=toor
# 会覆盖 `open-isle.env`
MYSQL_PORT=3306
MYSQL_DATABASE=openisle
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>
# 已迁移到仓库根目录的 .env.*.example 文件。
# 请复制对应环境的示例文件到项目根目录,例如:
# cp ../.env.dev.example ../.env
# docker-compose 将自动读取 ../.env。

1
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data

View File

@@ -2,25 +2,37 @@ services:
# MySQL service
mysql:
image: mysql:8.0
container_name: openisle-mysql
container_name: ${COMPOSE_PROJECT_NAME}-openisle-mysql
restart: always
env_file:
- ../backend/open-isle.env
- ./.env
- ${ENV_FILE:-../.env}
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_0900_ai_ci
--default-time-zone=+08:00
--skip-character-set-client-handshake
ports:
- "${MYSQL_PORT}:3306"
- "${MYSQL_PORT:-3306}:3306"
volumes:
- mysql-data:/var/lib/mysql
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d:ro
- ./mysql/conf.d:/etc/mysql/conf.d:ro
networks:
- openisle-network
healthcheck:
test: ["CMD","mysqladmin","ping","-h","127.0.0.1","-u","root","-p$MYSQL_ROOT_PASSWORD"]
interval: 5s
timeout: 3s
retries: 30
start_period: 20s
# OpenSearch Service
opensearch:
user: "1000:1000"
build:
context: .
dockerfile: Dockerfile
container_name: opensearch
dockerfile: opensearch.Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-opensearch
environment:
- cluster.name=os-single
- node.name=os-node-1
@@ -31,53 +43,259 @@ services:
- cluster.blocks.create_index=false
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
nofile: { soft: 65536, hard: 65536 }
volumes:
- ./data:/usr/share/opensearch/data
- ./snapshots:/snapshots
- opensearch-data:/usr/share/opensearch/data
- opensearch-snapshots:/snapshots
ports:
- "${OPENSEARCH_PORT:-9200}:9200"
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
restart: unless-stopped
healthcheck:
test:
- CMD-SHELL
- curl -fsS http://127.0.0.1:9200/_cluster/health >/dev/null
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
networks:
- openisle-network
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_name: os-dashboards
container_name: ${COMPOSE_PROJECT_NAME}-os-dashboards
environment:
- OPENSEARCH_HOSTS=["http://opensearch:9200"]
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true
OPENSEARCH_HOSTS: '["http://opensearch:9200"]'
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
ports:
- "${OPENSEARCH_DASHBOARDS_PORT:-5601}:5601"
depends_on:
- opensearch
restart: unless-stopped
networks:
- openisle-network
rabbitmq:
image: rabbitmq:3.13-management
container_name: ${COMPOSE_PROJECT_NAME}-openisle-rabbitmq
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_VHOST: "${RABBITMQ_VHOST:-/}"
ports:
- "${RABBITMQ_PORT:-5672}:5672"
- "${RABBITMQ_MANAGEMENT_PORT:-15672}:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
- ./rabbitmq/conf/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro
- ./rabbitmq/conf/enabled_plugins:/etc/rabbitmq/enabled_plugins:ro
- ./rabbitmq/definitions.json:/etc/rabbitmq/definitions.json:ro
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
networks:
- openisle-network
# Java spring boot service
redis:
image: redis:7
container_name: ${COMPOSE_PROJECT_NAME}-openisle-redis
restart: unless-stopped
env_file:
- ${ENV_FILE:-../.env}
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
networks:
- openisle-network
# Java spring boot service (开发便捷镜像,后续可换成打包镜像)
springboot:
image: maven:3.9-eclipse-temurin-17
container_name: openisle-springboot
container_name: ${COMPOSE_PROJECT_NAME}-openisle-springboot
working_dir: /app
env_file:
- ../backend/open-isle.env
- ./.env
- ${ENV_FILE:-../.env}
environment:
- MYSQL_URL=jdbc:mysql://mysql:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
TZ: "Asia/Shanghai"
SPRING_HEALTH_PATH: ${SPRING_HEALTH_PATH:-/actuator/health}
SERVER_PORT: ${SERVER_PORT:-8080}
RABBITMQ_PORT: 5672
OPENSEARCH_PORT: 9200
MYSQL_PORT: 3306
REDIS_PORT: 6379
JAVA_OPTS: "-Duser.timezone=Asia/Shanghai"
ports:
- "${SERVER_PORT}:8080"
- "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}"
volumes:
- ../backend:/app
- maven-repo:/root/.m2
depends_on:
- mysql
command: mvn clean spring-boot:run -Dmaven.test.skip=true
mysql:
condition: service_healthy
redis:
condition: service_started
rabbitmq:
condition: service_started
websocket-service:
condition: service_healthy
opensearch:
condition: service_healthy
command: >
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${SERVER_PORT:-8080}${SPRING_HEALTH_PATH:-/actuator/health} || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
networks:
- openisle-network
websocket-service:
image: maven:3.9-eclipse-temurin-17
container_name: ${COMPOSE_PROJECT_NAME}-openisle-websocket
working_dir: /app
env_file:
- ${ENV_FILE:-../.env}
environment:
WS_HEALTH_PATH: ${WS_HEALTH_PATH:-/actuator/health}
WEBSOCKET_PORT: ${WEBSOCKET_PORT:-8082}
SERVER_PORT: ${WEBSOCKET_PORT:-8082}
RABBITMQ_PORT: 5672
ports:
- "${WEBSOCKET_PORT:-8082}:${WEBSOCKET_PORT:-8082}"
volumes:
- ../websocket_service:/app
- websocket-maven-repo:/root/.m2
depends_on:
rabbitmq:
condition: service_healthy
command: >
sh -c "apt-get update && apt-get install -y --no-install-recommends curl &&
mvn clean spring-boot:run -Dmaven.test.skip=true"
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:${WEBSOCKET_PORT:-8082}${WS_HEALTH_PATH:-/actuator/health} || exit 1"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
networks:
- openisle-network
frontend_dev:
image: node:20
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend-dev
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:
springboot:
condition: service_healthy
websocket-service:
condition: service_healthy
networks:
- openisle-network
profiles:
- dev
frontend_service:
build:
context: ..
dockerfile: docker/frontend-service.Dockerfile
args:
NUXT_ENV: ${NUXT_ENV:-staging}
container_name: ${COMPOSE_PROJECT_NAME}-openisle-frontend
env_file:
- ${ENV_FILE:-../.env}
ports:
- "${FRONTEND_PORT:-3000}:3000"
depends_on:
springboot:
condition: service_healthy
websocket-service:
condition: service_healthy
restart: unless-stopped
profiles: ["staging", "prod"]
loopback_8080:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8080
# 监听“frontend_dev 容器自身的” 127.0.0.1:8080 → 转发到 springboot:8080
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork
- TCP4:springboot:8080
depends_on:
springboot:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8080"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
loopback_8082:
image: alpine/socat
container_name: ${COMPOSE_PROJECT_NAME}-loopback-8082
# 监听 127.0.0.1:8082 → 转发到 websocket-service:8082WS 纯 TCP 可直接过)
command:
- -d
- -d
- -ly
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
- TCP4:websocket-service:8082
depends_on:
websocket-service:
condition: service_healthy
network_mode: "service:frontend_dev"
profiles: ["dev"]
healthcheck:
test: ["CMD", "sh", "-c", "nc -z 127.0.0.1 8082"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
openisle-network:
name: "${COMPOSE_PROJECT_NAME}_net"
driver: bridge
volumes:
mysql-data:
name: "${COMPOSE_PROJECT_NAME}_mysql-data"
maven-repo:
name: "${COMPOSE_PROJECT_NAME}_maven-repo"
redis-data:
name: "${COMPOSE_PROJECT_NAME}_redis-data"
rabbitmq-data:
name: "${COMPOSE_PROJECT_NAME}_rabbitmq-data"
websocket-maven-repo:
name: "${COMPOSE_PROJECT_NAME}_websocket-maven-repo"
frontend-node-modules:
name: "${COMPOSE_PROJECT_NAME}_frontend-node-modules"
frontend-service-node-modules:
name: "${COMPOSE_PROJECT_NAME}_frontend-service-node-modules"
frontend-static:
name: "${COMPOSE_PROJECT_NAME}_frontend-static"
opensearch-data:
name: "${COMPOSE_PROJECT_NAME}_opensearch-data"
opensearch-snapshots:
name: "${COMPOSE_PROJECT_NAME}_opensearch-snapshots"

View File

@@ -0,0 +1,39 @@
# ==== builder ====
FROM node:20-bullseye AS builder
WORKDIR /app
# 通过构建参数选择环境staging / production默认 staging
ARG NUXT_ENV=staging
ENV NODE_ENV=production \
NUXT_TELEMETRY_DISABLED=1
# 复制源代码(假设仓库根目录包含 frontend_nuxt
# 构建上下文由 docker-compose 指向仓库根目录
COPY ./frontend_nuxt/package*.json /app/
RUN npm ci
# 拷贝剩余代码
COPY ./frontend_nuxt/ /app/
# 若存在环境样例文件,则在构建期复制为 .env你也可以用 --build-arg 覆盖)
RUN if [ -f ".env.${NUXT_ENV}.example" ]; then cp ".env.${NUXT_ENV}.example" .env; fi
# 构建 SSR产物在 .output
RUN npm run build
# ==== runner ====
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NUXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOST=0.0.0.0
# 复制构建产物
COPY --from=builder /app/.output /app/.output
# 健康检查(简洁起见,探测首页)
HEALTHCHECK --interval=10s --timeout=5s --retries=30 CMD wget -qO- http://127.0.0.1:${PORT}/ >/dev/null 2>&1 || exit 1
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -0,0 +1,10 @@
[mysqld]
character-set-server = utf8mb4
collation-server = utf8mb4_0900_ai_ci
skip-character-set-client-handshake
[client]
default-character-set = utf8mb4
[mysql]
default-character-set = utf8mb4

View File

@@ -0,0 +1 @@
[rabbitmq_management, rabbitmq_prometheus].

View File

@@ -0,0 +1,6 @@
# 管理插件加载 definitions仅空库时生效
management.load_definitions = /etc/rabbitmq/definitions.json
# (可选)禁用管理老式统计采集,转 Prometheus避免弃用告警
management_agent.disable_metrics_collector = true
management.disable_stats = true

View File

@@ -0,0 +1,31 @@
{
"users": [
{ "name": "nagisa", "password": "nagisa", "tags": "administrator" }
],
"vhosts": [{ "name": "/" }],
"permissions": [
{ "user": "nagisa", "vhost": "/", "configure": ".*", "write": ".*", "read": ".*" }
],
"queues": [
{ "name": "notifications-queue", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-0", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-1", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-2", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-3", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-4", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-5", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-6", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-7", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-8", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-9", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-a", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-b", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-c", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-d", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-e", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} },
{ "name": "notifications-queue-f", "vhost": "/", "durable": true, "auto_delete": false, "arguments": {} }
],
"exchanges": [],
"bindings": []
}

View File

@@ -1,12 +1,3 @@
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
NUXT_PUBLIC_WEBSOCKET_URL=https://127.0.0.1:8082
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
; 本地
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
# 如需在本地运行 Nuxt请复制对应的示例文件到项目根目录
# cp ../.env.dev.example ../.env

View File

@@ -1,19 +1,5 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
; 生产环境ws后端
NUXT_PUBLIC_WEBSOCKET_URL=https://open-isle.com/websocket
; 预发环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
# 根据环境选择对应文件复制至项目根目录:
# cp ../.env.dev.example ../.env
# cp ../.env.staging.example ../.env
# cp ../.env.production.example ../.env

View File

@@ -1,13 +1,3 @@
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
; 生产环境ws后端
NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
# 如需配置生产环境,请复制并修改对应示例文件:
# cp ../.env.production.example ../.env

View File

@@ -1,17 +1,3 @@
; 本地部署后端
; NUXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 预发环境ws后端
NUXT_PUBLIC_WEBSOCKET_URL=https://staging.open-isle.com/websocket
; 预发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
# 环境变量已统一迁移至仓库根目录的 .env.*.example 文件。
# 如需配置预发环境,请复制并修改对应示例文件:
# cp ../.env.staging.example ../.env

View File

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

View File

@@ -3,7 +3,7 @@
--primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5);
--secondary-color: rgb(255, 255, 255);
--secondary-color-hover: rgba(10, 111, 120, 0.184);
--secondary-color-hover: rgba(10, 111, 120, 0.079);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px;
--header-background-color: white;
@@ -54,6 +54,7 @@
--header-border-color: #555;
--primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151);
--secondary-color-hover: rgba(17, 182, 197, 0.238);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white;
--app-menu-background-color: #333;
@@ -179,7 +180,9 @@ body {
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
white-space: nowrap; /* 禁止数字换行 */
font-variant-numeric: tabular-nums; /* 数字等宽 */
/* width: 2em; */
font-size: 13px;
position: sticky;
flex-shrink: 0;
@@ -342,6 +345,16 @@ body {
line-height: 1.5;
}
/*处理iframe视频标签*/
.info-content-text iframe {
width: 100%;
max-width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* 保持 16:9 比例 */
border: none;
display: block;
}
.d2h-file-name {
font-size: 14px !important;
}
@@ -357,7 +370,10 @@ body {
.d2h-code-line {
padding-left: 10px !important;
}
/* 手机端不换行 */
.info-content-text code {
white-space: pre; /* 禁止自动换行 */
}
/* .d2h-diff-table {
font-size: 6px !important;
}

View File

@@ -119,7 +119,7 @@ export default {
.cropper-btn {
padding: 6px 12px;
border-radius: 4px;
border-radius: 10px;
color: var(--primary-color);
border: none;
background: transparent;
@@ -128,7 +128,7 @@ export default {
.cropper-btn.primary {
background: var(--primary-color);
color: var(--text-color);
color: #ffff;
border-color: var(--primary-color);
}

View File

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

View File

@@ -0,0 +1,187 @@
<template>
<div
ref="groupRef"
class="base-item-group"
:class="groupClass"
:style="groupStyle"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@focusin="onFocusIn"
@focusout="onFocusOut"
>
<div
v-for="(item, index) in normalizedItems"
:key="resolveKey(item, index)"
class="base-item-group-item"
:style="{ zIndex: getZIndex(index) }"
>
<slot name="item" :item="item" :index="index"></slot>
</div>
<slot name="after"></slot>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => [],
},
itemKey: {
type: [String, Function],
default: null,
},
overlap: {
type: [Number, String],
default: 12,
},
expandedGap: {
type: [Number, String],
default: 8,
},
direction: {
type: String,
default: 'horizontal',
validator: (value) => ['horizontal', 'vertical'].includes(value),
},
reverse: {
type: Boolean,
default: false,
},
animationDuration: {
type: [Number, String],
default: 200,
},
})
const groupRef = ref(null)
const state = reactive({
hovering: false,
focused: false,
})
const normalizedItems = computed(() => props.items || [])
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
const groupClass = computed(() => [
`base-item-group--${props.direction}`,
{
'is-expanded': isExpanded.value,
'is-reversed': props.reverse,
},
])
const groupStyle = computed(() => ({
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
}))
const isExpanded = computed(() => state.hovering || state.focused)
function onMouseEnter() {
state.hovering = true
}
function onMouseLeave() {
state.hovering = false
}
function onFocusIn() {
state.focused = true
}
function onFocusOut(event) {
const nextTarget = event.relatedTarget
if (!groupRef.value) {
state.focused = false
return
}
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
state.focused = false
}
}
function resolveKey(item, index) {
if (typeof props.itemKey === 'function') {
return props.itemKey(item, index)
}
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
return item[props.itemKey]
}
return index
}
function getZIndex(index) {
if (props.reverse) {
return index + 1
}
return normalizedItems.value.length - index
}
</script>
<style scoped>
.base-item-group {
--base-item-group-overlap: 12px;
--base-item-group-expanded-gap: 8px;
--base-item-group-transition-duration: 200ms;
display: inline-flex;
position: relative;
align-items: center;
}
.base-item-group:focus-within {
outline: none;
}
.base-item-group--horizontal {
flex-direction: row;
}
.base-item-group--horizontal.is-reversed {
flex-direction: row-reverse;
}
.base-item-group--vertical {
flex-direction: column;
align-items: flex-start;
}
.base-item-group--vertical.is-reversed {
flex-direction: column-reverse;
}
.base-item-group-item {
transition:
margin var(--base-item-group-transition-duration) ease,
transform var(--base-item-group-transition-duration) ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-left: calc(var(--base-item-group-overlap) * -1);
}
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
margin-left: var(--base-item-group-expanded-gap);
}
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
margin-top: calc(var(--base-item-group-overlap) * -1);
}
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
margin-top: var(--base-item-group-expanded-gap);
}
.base-item-group.is-expanded .base-item-group-item {
transform: translateZ(0);
}
</style>

View File

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

View File

@@ -53,14 +53,29 @@
@click="handleContentClick"
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
<ReactionsGroup
ref="commentReactionsGroupRef"
v-model="comment.reactions"
content-type="comment"
:content-id="comment.id"
/>
<div class="comment-reaction-actions">
<div
class="reaction-action like-action"
:class="{ selected: commentLikedByMe }"
@click="toggleCommentLike"
>
<like v-if="!commentLikedByMe" />
<like v-else theme="filled" />
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
</div>
<div class="reaction-action comment-reaction" @click="toggleEditor">
<comment-icon />
</div>
<div class="make-reaction-item copy-link" @click="copyCommentLink">
<div class="reaction-action copy-link" @click="copyCommentLink">
<link-icon />
</div>
</ReactionsGroup>
</div>
</div>
<div class="comment-editor-wrapper" ref="editorWrapper">
<CommentEditor
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const commentReactionsGroupRef = ref(null)
const commentLikeCount = computed(
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
)
const commentLikedByMe = computed(() =>
(props.comment.reactions || []).some(
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
),
)
const toggleCommentLike = () => {
commentReactionsGroupRef.value?.toggleReaction('LIKE')
}
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const isCommentFromPostAuthor = computed(() => {
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
</script>
<style scoped>
.comment-reaction-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.reaction-action {
cursor: pointer;
padding: 4px 10px;
border-radius: 10px;
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
opacity: 0.6;
font-size: 18px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 14px;
font-weight: bold;
}
.reply-toggle {
cursor: pointer;
color: var(--primary-color);
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
color: var(--primary-color);
}
.comment-reaction:hover {
background-color: lightgray;
}
.comment-highlight {
animation: highlight 2s;
}
@@ -424,6 +488,16 @@ const handleContentClick = (e) => {
font-weight: bold;
}
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 0px;
}
.medal-name {
font-size: 12px;
margin-left: 1px;

View File

@@ -0,0 +1,319 @@
<template>
<div class="donate-container">
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
<div class="donate-viewer" @click="openPanel">
<div
class="donate-viewer-item-container"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<BaseItemGroup
:items="donationList"
:overlap="10"
:expanded-gap="2"
:direction="vertical"
>
<template #item="{ item }">
<BaseUserAvatar
:user-id="item.userId"
:src="item.avatar"
:alt="item.username"
:width="20"
:disable-link="true"
/>
</template>
</BaseItemGroup>
<div class="donate-counts-text">{{ totalAmount }}</div>
</div>
</div>
</ToolTip>
<ToolTip content="赞赏作者" placement="bottom" v-else>
<div class="donate-viewer-item placeholder" @click="openPanel">
<financing class="donate-viewer-item-placeholder-icon" />
</div>
</ToolTip>
<div
v-if="panelVisible"
class="donate-panel"
ref="donatePanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<div
v-for="option in donateOptions"
:key="option"
class="donate-option"
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
@click="handleDonate(option)"
>
<financing class="donate-option-icon" />
<div class="donate-counts-text">{{ option }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { Finance } from '@icon-park/vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
const financing = Finance
const props = defineProps({
postId: {
type: [Number, String],
required: true,
},
authorId: {
type: [Number, String],
required: true,
},
isAuthor: {
type: Boolean,
default: false,
},
})
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const panelVisible = ref(false)
const donatePanelRef = ref(null)
const panelInlineStyle = ref({})
const donationSummary = ref({ totalAmount: 0, donations: [] })
const donating = ref(false)
let hideTimer = null
const donateOptions = [10, 30, 100]
const donationList = computed(() => donationSummary.value?.donations ?? [])
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
const isAuthorUser = computed(() => {
if (props.isAuthor) return true
if (!authState.userId || !props.authorId) return false
return Number(authState.userId) === Number(props.authorId)
})
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = donatePanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
width: 'max-content',
maxWidth: `${parentWidth}px`,
}
}
watch(panelVisible, async (visible) => {
if (visible) {
await nextTick()
updatePanelInlineStyle()
}
})
const normalizeSummary = (data) => ({
totalAmount: data?.totalAmount ?? 0,
donations: Array.isArray(data?.donations) ? data.donations : [],
})
const loadDonations = async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
if (!res.ok) return
const data = await res.json()
donationSummary.value = normalizeSummary(data)
} catch (e) {
// ignore network errors for donation summary
}
}
const handleDonate = async (amount) => {
if (!amount || donating.value) return
if (!authState.loggedIn) {
toast.error('请先登录后再打赏')
panelVisible.value = false
return
}
if (isAuthorUser.value) {
toast.warning('不能给自己打赏')
return
}
try {
donating.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token ? `Bearer ${token}` : '',
},
body: JSON.stringify({ amount }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
if (res.status === 401) {
toast.error('请先登录后再打赏')
} else {
toast.error(data?.error || '打赏失败')
}
return
}
donationSummary.value = normalizeSummary(data)
toast.success('打赏成功,感谢你的支持!')
panelVisible.value = false
} catch (e) {
toast.error('打赏失败,请稍后再试')
} finally {
donating.value = false
}
}
onMounted(async () => {
window.addEventListener('resize', updatePanelInlineStyle)
await loadDonations()
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
watch(
() => props.postId,
async () => {
donationSummary.value = { totalAmount: 0, donations: [] }
await loadDonations()
},
)
</script>
<style scoped>
.donate-container {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.donate-viewer-item-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-viewer {
border-radius: 13px;
padding: 3px;
padding-right: 6px;
cursor: pointer;
transition: background-color 0.5s ease;
}
.donate-viewer:hover {
background-color: var(--secondary-color-hover);
}
.donate-counts-text {
color: var(--primary-color);
font-size: 14px;
}
.donate-panel {
position: absolute;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
padding: 5px 10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
z-index: 10;
gap: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
.donate-viewer-item.placeholder {
display: flex;
cursor: pointer;
flex-direction: row;
padding: 2px 10px;
gap: 5px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;
background-color: var(--normal-light-background-color);
}
.donate-viewer-item {
font-size: 16px;
}
.donate-viewer-item-placeholder-icon {
opacity: 0.5;
}
.donate-option {
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.donate-option:hover {
background-color: var(--normal-light-background-color);
}
.donate-option.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.donate-option.disabled:hover {
background-color: transparent;
}
.donate-option-icon {
color: var(--primary-color);
}
@media (max-width: 768px) {
.donate-viewer-item.placeholder {
padding: 4px 8px;
gap: 3px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 3px;
margin-bottom: 3px;
font-size: 12px;
color: var(--text-color);
align-items: center;
}
}
</style>

View File

@@ -49,7 +49,11 @@
</slot>
</div>
<div
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
v-if="
open &&
!isMobile &&
(loading || filteredOptions.length > 0 || showSearch || (remote && search))
"
:class="['dropdown-menu', menuClass]"
v-click-outside="close"
ref="menuRef"
@@ -62,26 +66,29 @@
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</template>
</div>
<Teleport to="body">
@@ -99,26 +106,29 @@
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
<template v-else>
<div
v-for="o in filteredOptions"
:key="o.id"
@click="select(o.id)"
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
>
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
<template v-if="o.icon">
<BaseImage
v-if="isImageIcon(o.icon)"
:src="o.icon"
class="option-icon"
:alt="o.name"
/>
<component v-else :is="o.icon" class="option-icon" :size="16" />
</template>
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</template>
</div>
</div>
@@ -158,9 +168,19 @@ export default {
const mobileMenuRef = ref(null)
const isMobile = useIsMobile()
const openMenu = () => {
if (!open.value) {
open.value = true
}
}
const toggle = () => {
open.value = !open.value
if (!open.value) emit('close')
if (open.value) {
open.value = false
emit('close')
} else {
open.value = true
}
}
const close = () => {
@@ -265,7 +285,7 @@ export default {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
expose({ toggle, close, reload, scrollToBottom })
expose({ toggle, close, reload, scrollToBottom, openMenu })
return {
open,
@@ -283,6 +303,7 @@ export default {
isImageIcon,
setSearch,
isMobile,
remote: props.remote,
}
},
}
@@ -297,7 +318,6 @@ export default {
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
@@ -320,6 +340,7 @@ export default {
z-index: 10000;
max-height: 300px;
min-width: 350px;
margin-top: 4px;
overflow-y: auto;
}
@@ -384,6 +405,13 @@ export default {
padding: 10px 0;
}
.dropdown-empty {
padding: 20px;
text-align: center;
color: var(--muted-text-color, #8c8c8c);
font-size: 14px;
}
.dropdown-mobile-page {
position: fixed;
top: 0;

View File

@@ -26,40 +26,63 @@
<ClientOnly>
<div class="header-content-right">
<div v-if="isMobile" class="search-icon" @click="search">
<search-icon />
</div>
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
<component :is="iconClass" />
</div>
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
<copy />
邀请
<loading v-if="isCopying" />
</div>
<SearchDropdown
ref="searchDropdown"
v-if="!isMobile || showSearch"
@close="closeSearch"
/>
<!-- 搜索 -->
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
<div class="header-icon-item" @click="search">
<search-icon class="header-icon" />
<span class="header-label">搜索</span>
</div>
</ToolTip>
<!-- 主题切换 -->
<ToolTip v-if="isMobile" content="切换主题" placement="bottom">
<div class="header-icon-item" @click="cycleTheme">
<component :is="iconClass" class="header-icon" />
<span class="header-label">主题</span>
</div>
</ToolTip>
<!-- 邀请 -->
<ToolTip v-if="!isMobile" content="邀请好友" placement="bottom">
<div class="header-icon-item" @click="copyInviteLink">
<template v-if="!isCopying">
<copy-link class="header-icon" />
<span class="header-label">邀请</span>
</template>
<loading v-else />
</div>
</ToolTip>
<!-- 在线人数 -->
<ToolTip v-if="!isMobile" content="当前在线人数" placement="bottom">
<div class="online-count">
<peoples-two />
<span>{{ onlineCount }}</span>
<div class="header-icon-item">
<peoples-two class="header-icon" />
<span class="header-label">在线</span>
<span class="header-badge">{{ onlineCount }}</span>
</div>
</ToolTip>
<!-- RSS -->
<ToolTip content="复制RSS链接" placement="bottom">
<div class="rss-icon" @click="copyRssLink">
<rss />
<div class="header-icon-item" @click="copyRssLink">
<rss class="header-icon" />
<span class="header-label">RSS</span>
</div>
</ToolTip>
<!-- 发帖 -->
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<edit />
<div class="header-icon-item" @click="goToNewPost">
<edit class="header-icon" />
<span class="header-label">发帖</span>
</div>
</ToolTip>
<!-- 消息 -->
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
<div class="messages-icon" @click="goToMessages">
<message-emoji />
<div class="header-icon-item" @click="goToMessages">
<message-emoji class="header-icon" />
<span class="header-label">消息</span>
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
@@ -73,10 +96,9 @@
<BaseUserAvatar
class="avatar-img"
:user-id="authState.userId"
:src="avatar"
alt="avatar"
:width="32"
:src="authState.avatar"
:disable-link="true"
:width="32"
/>
<down />
</div>
@@ -89,7 +111,6 @@
</div>
</div>
</ClientOnly>
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
</div>
</header>
</template>
@@ -101,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { authState, clearToken } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
@@ -123,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const isCopying = ref(false)
const onlineCount = ref(0)
// 心跳检测
@@ -192,6 +211,7 @@ const copyInviteLink = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
isCopying.value = false // 🔥 修复:未登录时立即复原状态
return
}
try {
@@ -235,17 +255,7 @@ const copyRssLink = async () => {
}
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
return
}
let id = authState.username || authState.userId
if (!id) {
const user = await loadCurrentUser()
if (user) {
id = user.username || user.id
}
}
let id = authState.username || authState.id
if (id) {
navigateTo(`/users/${id}`, { replace: true })
}
@@ -289,14 +299,6 @@ const iconClass = computed(() => {
})
onMounted(async () => {
const updateAvatar = async () => {
if (authState.loggedIn) {
const user = await loadCurrentUser()
if (user && user.avatar) {
avatar.value = user.avatar
}
}
}
const updateUnread = async () => {
if (authState.loggedIn) {
fetchUnreadCount()
@@ -306,17 +308,8 @@ onMounted(async () => {
}
}
await updateAvatar()
await updateUnread()
watch(
() => authState.loggedIn,
async (isLoggedIn) => {
await updateAvatar()
await updateUnread()
},
)
// 新增的在线人数逻辑
sendPing()
fetchCount()
@@ -333,7 +326,7 @@ onMounted(async () => {
height: var(--header-height);
background-color: var(--background-color-blur);
backdrop-filter: var(--blur-10);
color: var(--header-text-color);
color: var(--primary-color);
border-bottom: 1px solid var(--header-border-color);
}
@@ -376,6 +369,7 @@ onMounted(async () => {
flex-direction: row;
align-items: center;
gap: 20px;
padding-right: 15px;
}
.micon {
@@ -464,16 +458,13 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text {
font-size: 12px;
cursor: pointer;
color: var(--primary-color);
}
.invite_text:hover {
opacity: 0.8;
text-decoration: underline;
}
.invite_text,
.online-count,
.rss-icon,
.new-post-icon,
.messages-icon {
@@ -484,8 +475,8 @@ onMounted(async () => {
.unread-badge {
position: absolute;
top: -5px;
right: -10px;
top: -4px;
right: -6px;
background-color: #ff4d4f;
color: white;
border-radius: 50%;
@@ -500,8 +491,8 @@ onMounted(async () => {
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
top: 0;
right: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
@@ -513,14 +504,60 @@ onMounted(async () => {
}
.online-count {
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
color: var(--primary-color);
cursor: default;
}
/* === 统一图标按钮风格 === */
.header-icon-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 14px;
color: var(--primary-color);
cursor: pointer;
position: relative;
transition:
color 0.25s ease,
transform 0.15s ease,
opacity 0.2s ease;
}
.header-icon-item:hover {
opacity: 0.8;
transform: translateY(-1px);
}
/* 点击时瞬间高亮 + 轻微缩放 */
.header-icon-item:active {
color: var(--primary-color-hover);
transform: scale(0.92);
}
.header-icon {
font-size: 20px;
line-height: 1;
}
.header-label {
font-size: 12px;
line-height: 1;
}
/* 在线人数的数字文字样式(无背景) */
.header-badge {
position: absolute;
top: -4px;
right: -6px;
color: var(--primary-color); /* 🔹 使用主题主色 */
background: none; /* 🔹 去掉背景 */
font-size: 11px; /* 字体稍微大一点以便清晰 */
font-weight: 600; /* 加一点权重让数字更醒目 */
line-height: 1;
padding: 0; /* 去掉内边距 */
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);
@@ -556,5 +593,12 @@ onMounted(async () => {
.header-content-right {
gap: 15px;
}
/* 手机不显示文字 */
.header-label {
display: none;
}
.header-badge {
display: none;
}
}
</style>

View File

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

View File

@@ -42,6 +42,9 @@
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
>系统已精密计算抽奖结果 (=゚ω゚)</span
>
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
>
</div>
<div class="change-log-time">{{ log.time }}</div>
<div

View File

@@ -18,9 +18,11 @@
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
<ToolTip content="发表心情" placement="bottom">
<div class="reactions-viewer-item placeholder" @click="openPanel">
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
</div>
</ToolTip>
</template>
<template v-else-if="displayedReactions.length">
<div
@@ -35,21 +37,11 @@
</template>
</div>
</div>
<div class="make-reaction-container">
<div
v-if="props.contentType !== 'message'"
class="make-reaction-item like-reaction"
@click="toggleReaction('LIKE')"
>
<like v-if="!userReacted('LIKE')" />
<like v-else theme="filled" />
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
</div>
<slot></slot>
</div>
<div
v-if="panelVisible"
class="reactions-panel"
ref="reactionsPanelRef"
:style="panelInlineStyle"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
@@ -69,7 +61,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
@@ -102,8 +94,6 @@ const counts = computed(() => {
})
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
const likeCount = computed(() => counts.value['LIKE'] || 0)
const userReacted = (type) =>
reactions.value.some((r) => r.type === type && r.user === authState.username)
@@ -152,9 +142,11 @@ const displayedReactions = computed(() => {
.map((type) => ({ type }))
})
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
const panelTypes = computed(() => sortedReactionTypes.value)
const panelVisible = ref(false)
const reactionsPanelRef = ref(null)
const panelInlineStyle = ref({})
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
@@ -170,6 +162,33 @@ const cancelHide = () => {
clearTimeout(hideTimer)
}
const updatePanelInlineStyle = () => {
if (!panelVisible.value) return
const panelEl = reactionsPanelRef.value
if (!panelEl) return
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
if (!parentEl) return
const parentWidth = parentEl.clientWidth - 20
panelInlineStyle.value = {
width: 'max-content',
maxWidth: `${parentWidth}px`,
}
}
watch(panelVisible, async (visible) => {
if (visible) {
await nextTick()
updatePanelInlineStyle()
}
})
watch(panelTypes, async () => {
if (panelVisible.value) {
await nextTick()
updatePanelInlineStyle()
}
})
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
@@ -245,6 +264,15 @@ const toggleReaction = async (type) => {
onMounted(async () => {
await initialize()
window.addEventListener('resize', updatePanelInlineStyle)
})
defineExpose({
toggleReaction,
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
</script>
@@ -253,11 +281,7 @@ onMounted(async () => {
position: relative;
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.reactions-viewer {
@@ -295,40 +319,15 @@ onMounted(async () => {
padding-left: 5px;
}
.make-reaction-container {
display: flex;
flex-direction: row;
gap: 10px;
}
.make-reaction-item {
cursor: pointer;
padding: 4px;
opacity: 0.5;
border-radius: 8px;
font-size: 20px;
}
.like-reaction {
color: #ff0000;
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
.make-reaction-item:hover {
background-color: #ffe2e2;
}
.reactions-count {
font-size: 16px;
font-weight: bold;
margin-right: 15px;
}
.reactions-panel {
position: absolute;
bottom: 50px;
bottom: 35px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
@@ -361,7 +360,6 @@ onMounted(async () => {
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;

View File

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

View File

@@ -9,7 +9,9 @@ export default defineNuxtConfig({
modules: ['@nuxt/image'],
runtimeConfig: {
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
apiBaseUrl: process.server
? process.env.NUXT_PUBLIC_API_BASE_URL_SSR
: process.env.NUXT_PUBLIC_API_BASE_URL,
websocketUrl: process.env.NUXT_PUBLIC_WEBSOCKET_URL || '',
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',

View File

@@ -1,9 +1,9 @@
<template>
<div class="home-page">
<div v-if="!isMobile" class="search-container">
<!-- <div v-if="!isMobile" class="search-container">
<div class="search-title">一切可能从此刻启航在此遇见灵感与共鸣</div>
<SearchDropdown />
</div>
</div> -->
<div class="topic-container">
<div class="topic-item-container">
@@ -75,8 +75,8 @@
<star v-if="!article.rssExcluded" class="featured-icon" />
{{ article.title }}
</NuxtLink>
<NuxtLink class="article-item-description main-item">
{{ sanitizeDescription(article.description) }}
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
<div v-html="stripMarkdownWithTiebaMoji(article.description, 500)"></div>
</NuxtLink>
<div class="article-info-container main-item">
<ArticleCategory :category="article.category" />
@@ -143,6 +143,7 @@ import { useIsMobile } from '~/utils/screen'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import TimeManager from '~/utils/time'
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
useHead({
title: 'OpenIsle - 全面开源的自由社区',
meta: [
@@ -378,9 +379,6 @@ onBeforeUnmount(() => {
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
const ioKey = computed(() => asyncKey.value.join('::'))
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
// 页面选项同步到全局状态
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
selectedCategoryGlobal.value = newCategory
@@ -537,16 +535,22 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
.article-comments,
.header-item.comments {
width: 5%;
justify-content: flex-end;
text-align: right;
}
.article-views,
.header-item.views {
width: 5%;
justify-content: flex-end;
text-align: right;
}
.article-time,
.header-item.activity {
width: 10%;
justify-content: flex-end;
text-align: left;
}
.article-item-title {

View File

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

View File

@@ -61,14 +61,31 @@
@click="handleContentClick"
></div>
</div>
<ReactionsGroup
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
</ReactionsGroup>
<div class="message-reaction-row">
<ReactionsGroup
:ref="(el) => setMessageReactionRef(item.id, el)"
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
/>
<div class="message-reaction-actions">
<div
class="reaction-action like-action"
:class="{ selected: isMessageLiked(item) }"
@click="toggleMessageLike(item)"
>
<like v-if="!isMessageLiked(item)" />
<like v-else theme="filled" />
<span v-if="getMessageLikeCount(item)" class="reaction-count">{{
getMessageLikeCount(item)
}}</span>
</div>
<div @click="setReply(item)" class="reaction-action reply-btn">
<next /> 写个回复...
</div>
</div>
</div>
</template>
</BaseTimeline>
<div class="empty-container">
@@ -180,6 +197,32 @@ function setReply(message) {
replyTo.value = message
}
const messageReactionRefs = new Map()
function setMessageReactionRef(id, el) {
if (el) {
messageReactionRefs.set(id, el)
} else {
messageReactionRefs.delete(id)
}
}
function getMessageLikeCount(message) {
return (message.reactions || []).filter((reaction) => reaction.type === 'LIKE').length
}
function isMessageLiked(message) {
const username = currentUser.value?.username
if (!username) return false
return (message.reactions || []).some(
(reaction) => reaction.type === 'LIKE' && reaction.user === username,
)
}
function toggleMessageLike(message) {
const group = messageReactionRefs.get(message.id)
group?.toggleReaction('LIKE')
}
/** 改造:滚动函数 —— smooth & instant */
function scrollToBottomSmooth() {
const el = messagesListEl.value
@@ -710,6 +753,55 @@ function goBack() {
background-color: var(--normal-light-background-color);
}
.message-reaction-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin-top: 6px;
}
.message-reaction-actions {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.reaction-action {
cursor: pointer;
padding: 4px 10px;
border-radius: 10px;
display: flex;
align-items: center;
gap: 5px;
opacity: 0.6;
font-size: 16px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 14px;
font-weight: bold;
}
.reply-header {
display: flex;
flex-direction: row;
@@ -723,14 +815,8 @@ function goBack() {
}
.reply-btn {
cursor: pointer;
padding: 4px;
opacity: 0.6;
font-size: 12px;
}
.reply-btn:hover {
opacity: 1;
color: var(--primary-color);
}
.active-reply {

View File

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

View File

@@ -75,7 +75,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
</NuxtLink>
</span>
回复了
@@ -85,7 +85,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -115,7 +115,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</span>
</NotificationContainer>
@@ -162,7 +162,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</span>
进行了表态
@@ -267,7 +267,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -287,7 +287,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.parentComment.content, 500)"></span>
</NuxtLink>
回复了
<NuxtLink
@@ -295,7 +295,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -323,7 +323,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -342,7 +342,7 @@
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.comment.content, 500)"></span>
</NuxtLink>
</NotificationContainer>
</template>
@@ -542,6 +542,27 @@
被收录为精选
</NotificationContainer>
</template>
<template v-else-if="item.type === 'DONATION'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
在帖子
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
打赏了你
<template v-if="item.content"> 获得 {{ item.content }} 积分 </template>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_DELETED'">
<NotificationContainer :item="item" :markRead="markRead">
管理员
@@ -556,7 +577,7 @@
</template>
删除了您的帖子
<span class="notif-content-text">
{{ stripMarkdownLength(item.content, 100) }}
<span v-html="stripMarkdownWithTiebaMoji(item.content, 500)"></span>
</span>
</NotificationContainer>
</template>
@@ -586,7 +607,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown'
import { stripMarkdownWithTiebaMoji } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,

View File

@@ -184,6 +184,27 @@
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'DONATE_SENT'">
你在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
中打赏了
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'DONATE_RECEIVED'">
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
中打赏了你获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<paper-money-two /> 你目前的积分是 {{ item.balance }}
</div>
@@ -248,6 +269,8 @@ const iconMap = {
FEATURE: 'star',
LOTTERY_JOIN: 'medal-one',
LOTTERY_REWARD: 'fireworks',
DONATE_SENT: 'paper-money-two',
DONATE_RECEIVED: 'paper-money-two',
POST_LIKE_CANCELLED: 'clear-icon',
COMMENT_LIKE_CANCELLED: 'clear-icon',
}

View File

@@ -92,11 +92,29 @@
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
<div class="make-reaction-item copy-link" @click="copyPostLink">
<div class="article-option-container">
<ReactionsGroup
ref="postReactionsGroupRef"
v-model="postReactions"
content-type="post"
:content-id="postId"
/>
<DonateGroup :post-id="postId" :author-id="author.id" :is-author="isAuthor" />
</div>
<div class="article-footer-actions">
<div
class="reaction-action like-action"
:class="{ selected: postLikedByMe }"
@click="togglePostLike"
>
<like v-if="!postLikedByMe" />
<like v-else theme="filled" />
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
</div>
<div class="reaction-action copy-link" @click="copyPostLink">
<link-icon />
</div>
</ReactionsGroup>
</div>
</div>
</div>
</div>
@@ -196,6 +214,7 @@ import PostChangeLogItem from '~/components/PostChangeLogItem.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DonateGroup from '~/components/DonateGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
@@ -223,6 +242,18 @@ const postContent = ref('')
const category = ref('')
const tags = ref([])
const postReactions = ref([])
const postReactionsGroupRef = ref(null)
const postLikeCount = computed(
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
)
const postLikedByMe = computed(() =>
postReactions.value.some(
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
),
)
const togglePostLike = () => {
postReactionsGroupRef.value?.toggleReaction('LIKE')
}
const comments = ref([])
const changeLogs = ref([])
const status = ref('PUBLISHED')
@@ -366,7 +397,11 @@ const changeLogIcon = (l) => {
return 'unlock'
}
} else if (l.type === 'PINNED') {
return 'pin-icon'
if (l.newPinnedAt) {
return 'pin'
} else {
return 'clear-icon'
}
} else if (l.type === 'FEATURED') {
if (l.newFeatured) {
return 'star'
@@ -377,6 +412,8 @@ const changeLogIcon = (l) => {
return 'check-one'
} else if (l.type === 'LOTTERY_RESULT') {
return 'gift'
} else if (l.type === 'DONATE') {
return 'financing'
} else {
return 'info'
}
@@ -401,6 +438,7 @@ const mapChangeLog = (l) => ({
newCategory: l.newCategory,
oldTags: l.oldTags,
newTags: l.newTags,
amount: l.amount,
icon: changeLogIcon(l),
})
@@ -1241,35 +1279,61 @@ onMounted(async () => {
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.reactions-viewer {
.article-option-container {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.reactions-viewer-item-container {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
}
.reactions-viewer-item {
font-size: 16px;
}
.make-reaction-container {
.article-footer-actions {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
}
.copy-link:hover {
.reaction-action {
cursor: pointer;
padding: 4px 10px;
opacity: 0.6;
border-radius: 10px;
font-size: 20px;
display: flex;
align-items: center;
gap: 5px;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
}
.reaction-action:hover {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-action.like-action {
color: #ff0000;
}
.reaction-action.selected {
opacity: 1;
background-color: var(--normal-light-background-color);
}
.reaction-count {
font-size: 16px;
font-weight: bold;
}
.reaction-action.copy-link:hover {
background-color: #e2e2e2;
}
@@ -1318,6 +1382,7 @@ onMounted(async () => {
.article-footer-container {
margin-top: 0;
margin-bottom: 0px;
}
.loading-container {

View File

@@ -5,11 +5,18 @@
<div class="reason-description">
为了我们社区的良性发展请填写注册理由我们将根据你的理由审核你的注册, 谢谢!
</div>
<div class="reason-input-container">
<BaseInput textarea rows="4" v-model="reason" placeholder="20个字以上"></BaseInput>
<div class="char-count">{{ reason.length }}/20</div>
<div class="input-wrapper">
<div class="reason-input-container">
<BaseInput
textarea
rows="4"
v-model="reason"
placeholder="请输入至少20个字符"
></BaseInput>
<div class="char-count">{{ reason.length }}/20</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">
提交
</div>
@@ -38,8 +45,9 @@ onMounted(async () => {
})
const submit = async () => {
if (!reason.value || reason.value.trim().length < 20) {
error.value = '请至少输入20个字'
const trimmedReason = reason.value.trim()
if (!trimmedReason || trimmedReason.length < 20) {
error.value = '请至少输入20个字符'
return
}
@@ -98,16 +106,29 @@ const submit = async () => {
width: 400px;
}
.input-wrapper {
display: flex;
flex-direction: column;
}
.reason-input-container {
position: relative;
}
.char-count {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 12px;
color: #888;
width: 100%;
text-align: right;
background-color: transparent;
pointer-events: none;
}
.error-message {
color: red;
font-size: 14px;
margin-top: 8px;
}
.signup-page-button-primary {

View File

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

View File

@@ -849,7 +849,8 @@ watch(selectedTab, async (val) => {
display: flex;
flex-direction: column;
padding: 20px;
gap: 20px;
row-gap: 40px; /* 行间距 */
column-gap: 20px; /* 列间距 */
}
.summary-title {
@@ -888,10 +889,10 @@ watch(selectedTab, async (val) => {
}
.summary-divider {
margin-top: 20px;
display: flex;
flex-direction: row;
gap: 20px;
row-gap: 40px; /* 行间距 */
column-gap: 20px; /* 列间距 */
width: 100%;
flex-wrap: wrap;
}

View File

@@ -29,6 +29,7 @@ import {
ApplicationMenu,
Search,
Copy,
CopyLink,
Loading,
Rss,
MessageEmoji,
@@ -79,6 +80,7 @@ import {
Dislike,
CheckOne,
Share,
Financing,
} from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => {
@@ -111,6 +113,7 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('ApplicationMenu', ApplicationMenu)
nuxtApp.vueApp.component('SearchIcon', Search)
nuxtApp.vueApp.component('Copy', Copy)
nuxtApp.vueApp.component('CopyLink', CopyLink)
nuxtApp.vueApp.component('Loading', Loading)
nuxtApp.vueApp.component('Rss', Rss)
nuxtApp.vueApp.component('MessageEmoji', MessageEmoji)
@@ -161,4 +164,5 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne)
nuxtApp.vueApp.component('Share', Share)
nuxtApp.vueApp.component('Financing', Financing)
})

View File

@@ -1 +0,0 @@
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -157,6 +157,7 @@ const SANITIZE_CFG = {
'th',
'video',
'source',
'iframe',
],
// 允许的属性
allowedAttributes: {
@@ -180,6 +181,16 @@ const SANITIZE_CFG = {
'crossorigin',
],
source: ['src', 'type'],
iframe: [
'src',
'title',
'width',
'height',
'allow',
'allowfullscreen',
'frameborder',
'referrerpolicy',
],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {
@@ -254,3 +265,26 @@ export function stripMarkdownLength(text, length) {
}
return plain.slice(0, length) + '...'
}
// 朴素文本带贴吧表情
export function stripMarkdownWithTiebaMoji(text, length){
console.error(text)
if (!text) return ''
// Markdown 转成纯文本
const plain = stripMarkdown(text)
console.error(plain)
// 替换 :tieba123: 为 <img>
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
const key = `tieba${num}`
const file = tiebaEmoji[key]
return file
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
: match // 没有匹配到图片则保留原样
})
// 截断纯文本长度(防止撑太长)
const truncated = withEmoji.length > length ? withEmoji.slice(0, length) + '...' : withEmoji
return truncated
}

View File

@@ -31,6 +31,7 @@ const iconMap = {
MENTION: 'HashtagKey',
POST_DELETED: 'ClearIcon',
POST_FEATURED: 'Star',
DONATION: 'PaperMoneyTwo',
}
export async function fetchUnreadCount() {
@@ -334,6 +335,18 @@ function createFetchNotifications() {
}
},
})
} else if (n.type === 'DONATION') {
arr.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
arr.push({
...n,

View File

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

View File

@@ -93,9 +93,8 @@ function getCircle(event) {
function withViewTransition(event, applyFn, direction = true) {
if (typeof document !== 'undefined' && document.startViewTransition) {
const transition = document.startViewTransition(async () => {
const transition = document.startViewTransition(() => {
applyFn()
await nextTick()
})
transition.ready
@@ -111,6 +110,7 @@ function withViewTransition(event, applyFn, direction = true) {
{
duration: 400,
easing: 'ease-in-out',
fill: 'both',
pseudoElement: direction
? '::view-transition-new(root)'
: '::view-transition-old(root)',

View File

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

130
nginx/openisle Normal file
View File

@@ -0,0 +1,130 @@
server {
listen 443 ssl;
server_name open-isle.com www.open-isle.com;
ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
add_header Cache-Control "no-store" always;
add_header X-Upstream $upstream_addr always;
}
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 通过 https://open-isle.com/rabbitmq/ 访问管理界面
location ^~ /rabbitmq/ {
# 关键点proxy_pass 以 "/" 结尾,保留后缀子路径映射
proxy_pass http://127.0.0.1:15672/;
proxy_http_version 1.1;
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_read_timeout 300s;
proxy_send_timeout 300s;
# 把上游返回的绝对重定向 /... 改写为 /rabbitmq/...
proxy_redirect ~^(/.*)$ /rabbitmq$1;
# 为了做 HTML/CSS/JS 内绝对路径替换,需要关闭压缩
proxy_set_header Accept-Encoding "";
# 将页面中以 "/" 开头的 src/href 替换为 "/rabbitmq/..."
sub_filter_types text/html text/css application/javascript;
sub_filter 'href="/' 'href="/rabbitmq/';
sub_filter 'src="/' 'src="/rabbitmq/';
sub_filter_once off;
# 建议对管理台再加一道保护(可选)
# auth_basic "RabbitMQ Console";
# auth_basic_user_file /etc/nginx/.htpasswd;
}
# 通过 https://open-isle.com/docker/ 访问 Portainer上游是自签名 HTTPS
location ^~ /docker/ {
proxy_pass https://127.0.0.1:19000/; # 末尾 / 保留子路径
proxy_http_version 1.1;
# 上游是自签证书,关闭校验(仅内网/自签场景)
proxy_ssl_verify off;
# 透传头
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;
# WebSocket/事件流Portainer 某些功能会用到)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
proxy_cache off;
# 把上游返回的绝对重定向 /... 改写为 /docker/...
proxy_redirect ~^(/.*)$ /docker$1;
# 为了替换 HTML/CSS/JS 中的绝对路径,需要关闭压缩
proxy_set_header Accept-Encoding "";
# 将页面中以 "/" 开头的 src/href 替换为 "/docker/..."
sub_filter_types text/html text/css application/javascript;
sub_filter 'href="/' 'href="/docker/';
sub_filter 'src="/' 'src="/docker/';
sub_filter_once off;
# 可选:再加一道基本认证
# auth_basic "Portainer";
# auth_basic_user_file /etc/nginx/.htpasswd;
}
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
location ^~ /websocket/ {
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;
}
}
server {
listen 80;
server_name open-isle.com www.open-isle.com;
return 301 https://$host$request_uri;
}

133
nginx/openisle-staging Normal file
View File

@@ -0,0 +1,133 @@
# 放在 http { } 里一次定义
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name staging.open-isle.com www.staging.open-isle.com;
ssl_certificate /etc/letsencrypt/live/staging.open-isle.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.open-isle.com/privkey.pem;
# ssl_certificate /etc/letsencrypt/live/open-isle.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/open-isle.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# ---------- SSR ----------
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
# 正确的升级头(仅在有 Upgrade 时)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 透传真实主机/协议/源 IP
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;
# 合理超时,避免 SSR 首屏慢查询导致 502/504
proxy_read_timeout 120s;
proxy_send_timeout 120s;
add_header Cache-Control "no-store" 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 ----------
location /api/ {
proxy_pass http://127.0.0.1:8081/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header 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 120s;
proxy_send_timeout 120s;
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# ---------- WEBSOCKET GATEWAY TO :8083 ----------
location ^~ /websocket/ {
proxy_pass http://127.0.0.1:8083/;
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;
}
}

View File

@@ -51,10 +51,10 @@
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-actuator</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -43,6 +43,8 @@ public class SecurityConfig {
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"http://frontend_dev:3000",
"http://frontend_service:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
));

View File

@@ -1,4 +1,4 @@
server.port=${SERVER_PORT:8082}
server.port=${WEBSOCKET_PORT:8082}
# 服务器配置
spring.application.name=websocket-service
@@ -19,4 +19,7 @@ logging.level.org.springframework.messaging=${MESSAGING_LOG_LEVEL:DEBUG}
logging.level.org.springframework.web.socket=${WEBSOCKET_LOG_LEVEL:DEBUG}
# 网站 URL 配置
app.website-url=${WEBSITE_URL:https://www.open-isle.com}
app.website-url=${WEBSITE_URL:https://www.open-isle.com}
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true

View File

@@ -1,3 +1,5 @@
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
# 如需在独立环境中运行,可参考以下字段:
SERVER_PORT=<your-server-port>
# RabbitMQ 配置