mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-06 23:21:16 +08:00
Merge remote-tracking branch 'origin/main' into feat_category_proposal
This commit is contained in:
118
.env.example
Normal file
118
.env.example
Normal 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
|
||||
|
||||
7
.github/workflows/deploy-docs.yml
vendored
7
.github/workflows/deploy-docs.yml
vendored
@@ -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
|
||||
|
||||
|
||||
13
.github/workflows/deploy-staging.yml
vendored
13
.github/workflows/deploy-staging.yml
vendored
@@ -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
|
||||
|
||||
13
.github/workflows/deploy.yml
vendored
13
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
292
CONTRIBUTING.md
292
CONTRIBUTING.md
@@ -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 登录业务,就需要谷歌相关的变量,数据库是一定要的
|
||||
|
||||

|
||||
|
||||
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 追踪,通常不推荐。
|
||||
|
||||

|
||||
|
||||
#### 配置 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
|
||||
|
||||

|
||||
|
||||
#### 配置 MySQL
|
||||
|
||||
> [!TIP]
|
||||
> 如果不知道怎么配置数据库可以参考 [Docker 环境](#docker-环境) 章节
|
||||
|
||||
1. 本机配置 MySQL 服务(网上很多教程,忽略)
|
||||
- 可以用 Laragon,自带 MySQL 包括 Nodejs,版本建议 `6.x`,`7` 以后需要 Lisence
|
||||
- [下载地址](https://github.com/leokhoa/laragon/releases)
|
||||
|
||||
2. 填写环境变量
|
||||
|
||||

|
||||
|
||||
```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**
|
||||
|
||||

|
||||
|
||||
#### 配置 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 应用。
|
||||
|
||||

|
||||
|
||||
### 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环境
|
||||
|
||||

|
||||
|
||||
|
||||
176
SECURITY.md
Normal file
176
SECURITY.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We take the security of OpenIsle seriously. The following versions are currently being supported with security updates:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.0.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
### How to Report a Security Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
Instead, please report them via one of the following methods:
|
||||
|
||||
1. **Email**: Send a detailed report to the project maintainer (check the repository for contact information)
|
||||
2. **GitHub Security Advisory**: Use GitHub's private vulnerability reporting feature at https://github.com/nagisa77/OpenIsle/security/advisories/new
|
||||
|
||||
### What to Include in Your Report
|
||||
|
||||
To help us better understand the nature and scope of the issue, please include as much of the following information as possible:
|
||||
|
||||
- Type of issue (e.g., SQL injection, XSS, authentication bypass, etc.)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit it
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial Response**: We will acknowledge your report within 48 hours
|
||||
- **Status Updates**: We will provide status updates at least every 5 business days
|
||||
- **Resolution**: We aim to resolve critical vulnerabilities within 30 days of disclosure
|
||||
|
||||
### What to Expect
|
||||
|
||||
After you submit a report:
|
||||
|
||||
1. We will confirm receipt of your vulnerability report and may ask for additional information
|
||||
2. We will investigate the issue and determine its impact and severity
|
||||
3. We will work on a fix and coordinate disclosure timing with you
|
||||
4. Once the fix is ready, we will release it and publicly acknowledge your contribution (unless you prefer to remain anonymous)
|
||||
|
||||
## Security Considerations for Deployment
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **JWT Tokens**: Ensure `JWT_SECRET` environment variable is set to a strong, random value (minimum 256 bits)
|
||||
- **OAuth Credentials**: Keep OAuth client secrets secure and never commit them to version control
|
||||
- **Session Management**: Configure appropriate session timeout values
|
||||
|
||||
### Database Security
|
||||
|
||||
- Use strong database passwords
|
||||
- Never expose database ports publicly
|
||||
- Use database connection encryption when available
|
||||
- Regularly backup your database
|
||||
|
||||
### API Security
|
||||
|
||||
- Enable rate limiting to prevent abuse
|
||||
- Validate all user inputs on both client and server side
|
||||
- Use HTTPS in production environments
|
||||
- Configure CORS properly to restrict origins
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following sensitive environment variables should be kept secure:
|
||||
|
||||
- `JWT_SECRET` - JWT signing key
|
||||
- `GOOGLE_CLIENT_SECRET` - Google OAuth credentials
|
||||
- `GITHUB_CLIENT_SECRET` - GitHub OAuth credentials
|
||||
- `DISCORD_CLIENT_SECRET` - Discord OAuth credentials
|
||||
- `TWITTER_CLIENT_SECRET` - Twitter OAuth credentials
|
||||
- `WEBPUSH_PRIVATE_KEY` - Web push notification private key
|
||||
- Database connection strings and credentials
|
||||
- Cloud storage credentials (Tencent COS)
|
||||
|
||||
**Never commit these values to version control or expose them in logs.**
|
||||
|
||||
### File Upload Security
|
||||
|
||||
- Validate file types and sizes
|
||||
- Scan uploaded files for malware
|
||||
- Store uploaded files outside the web root
|
||||
- Use cloud storage with proper access controls
|
||||
|
||||
### Password Security
|
||||
|
||||
- Configure password strength requirements via environment variables
|
||||
- Use bcrypt or similar strong hashing algorithms (already implemented in Spring Security)
|
||||
- Implement account lockout after failed login attempts
|
||||
|
||||
### Web Push Notifications
|
||||
|
||||
- Keep `WEBPUSH_PRIVATE_KEY` secret and secure
|
||||
- Only send notifications to users who have explicitly opted in
|
||||
- Validate notification payloads
|
||||
|
||||
### Dependency Management
|
||||
|
||||
- Regularly update dependencies to patch known vulnerabilities
|
||||
- Run `mvn dependency-check:check` to scan for vulnerable dependencies
|
||||
- Monitor GitHub security advisories for this project
|
||||
|
||||
### Production Deployment Checklist
|
||||
|
||||
- [ ] Use HTTPS/TLS for all connections
|
||||
- [ ] Set strong, unique secrets for all environment variables
|
||||
- [ ] Enable CSRF protection
|
||||
- [ ] Configure secure headers (CSP, X-Frame-Options, etc.)
|
||||
- [ ] Disable debug mode and verbose error messages
|
||||
- [ ] Set up proper logging and monitoring
|
||||
- [ ] Implement rate limiting and DDoS protection
|
||||
- [ ] Regular security updates and patches
|
||||
- [ ] Database backups and disaster recovery plan
|
||||
- [ ] Restrict admin access to trusted IPs when possible
|
||||
|
||||
## Known Security Features
|
||||
|
||||
OpenIsle includes the following security features:
|
||||
|
||||
- JWT-based authentication with configurable expiration
|
||||
- OAuth 2.0 integration with major providers
|
||||
- Password strength validation
|
||||
- Protection codes for sensitive operations
|
||||
- Input validation and sanitization
|
||||
- SQL injection prevention through ORM (JPA/Hibernate)
|
||||
- XSS protection in Vue.js templates
|
||||
- CSRF protection (Spring Security)
|
||||
|
||||
## Security Best Practices for Contributors
|
||||
|
||||
- Never commit credentials, API keys, or secrets
|
||||
- Follow secure coding practices (OWASP Top 10)
|
||||
- Validate and sanitize all user inputs
|
||||
- Use parameterized queries for database operations
|
||||
- Implement proper error handling without exposing sensitive information
|
||||
- Write security tests for new features
|
||||
- Review code for security issues before submitting PRs
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
When we receive a security bug report, we will:
|
||||
|
||||
1. Confirm the problem and determine affected versions
|
||||
2. Audit code to find any similar problems
|
||||
3. Prepare fixes for all supported versions
|
||||
4. Release patches as soon as possible
|
||||
|
||||
We appreciate your help in keeping OpenIsle and its users safe!
|
||||
|
||||
## Attribution
|
||||
|
||||
We believe in recognizing security researchers who help improve OpenIsle's security. With your permission, we will acknowledge your contribution in:
|
||||
|
||||
- Security advisory
|
||||
- Release notes
|
||||
- A security hall of fame (if established)
|
||||
|
||||
If you prefer to remain anonymous, we will respect your wishes.
|
||||
|
||||
## Contact
|
||||
|
||||
For any security-related questions or concerns, please reach out through the channels mentioned above.
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep OpenIsle secure!
|
||||
@@ -1,3 +1,6 @@
|
||||
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
|
||||
# 此文件保留作参考用途,如需在 Docker 之外手动配置,可按需复制。
|
||||
|
||||
# === Spring Boot ===
|
||||
SERVER_PORT=8080
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ")) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/DonationDto.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationDto {
|
||||
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String avatar;
|
||||
private int amount;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
11
backend/src/main/java/com/openisle/dto/DonationRequest.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationRequest {
|
||||
|
||||
private int amount;
|
||||
}
|
||||
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
15
backend/src/main/java/com/openisle/dto/DonationResponse.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class DonationResponse {
|
||||
|
||||
private int totalAmount;
|
||||
private List<DonationDto> donations = new ArrayList<>();
|
||||
private Integer balance;
|
||||
}
|
||||
@@ -29,4 +29,5 @@ public class PostChangeLogDto {
|
||||
private LocalDateTime newPinnedAt;
|
||||
private Boolean oldFeatured;
|
||||
private Boolean newFeatured;
|
||||
private Integer amount;
|
||||
}
|
||||
|
||||
@@ -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; // 泛型,具体类型由外部决定
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ public enum PointHistoryType {
|
||||
REDEEM,
|
||||
LOTTERY_JOIN,
|
||||
LOTTERY_REWARD,
|
||||
DONATE_SENT,
|
||||
DONATE_RECEIVED,
|
||||
}
|
||||
|
||||
@@ -10,4 +10,5 @@ public enum PostChangeType {
|
||||
FEATURED,
|
||||
VOTE_RESULT,
|
||||
LOTTERY_RESULT,
|
||||
DONATE,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "post_donate_change_logs")
|
||||
public class PostDonateChangeLog extends PostChangeLog {
|
||||
|
||||
@Column(nullable = false)
|
||||
private int amount;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
13
backend/src/main/resources/db/init/00_init_db_and_user.sql
Normal file
13
backend/src/main/resources/db/init/00_init_db_and_user.sql
Normal 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`;
|
||||
54
backend/src/main/resources/db/init/01_schema.sql
Normal file
54
backend/src/main/resources/db/init/01_schema.sql
Normal 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;
|
||||
26
backend/src/main/resources/db/init/02_seed_data.sql
Normal file
26
backend/src/main/resources/db/init/02_seed_data.sql
Normal 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;
|
||||
@@ -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
56
deploy/deploy.sh
Normal 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
56
deploy/deploy_staging.sh
Normal 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)"
|
||||
@@ -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
1
docker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
data
|
||||
@@ -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:8082(WS 纯 TCP 可直接过)
|
||||
command:
|
||||
- -d
|
||||
- -d
|
||||
- -ly
|
||||
- TCP4-LISTEN:8082,bind=127.0.0.1,reuseaddr,fork
|
||||
- TCP4:websocket-service:8082
|
||||
depends_on:
|
||||
websocket-service:
|
||||
condition: service_healthy
|
||||
network_mode: "service:frontend_dev"
|
||||
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"
|
||||
|
||||
39
docker/frontend-service.Dockerfile
Normal file
39
docker/frontend-service.Dockerfile
Normal 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"]
|
||||
10
docker/mysql/conf.d/charset.cnf
Normal file
10
docker/mysql/conf.d/charset.cnf
Normal 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
|
||||
1
docker/rabbitmq/conf/enabled_plugins
Normal file
1
docker/rabbitmq/conf/enabled_plugins
Normal file
@@ -0,0 +1 @@
|
||||
[rabbitmq_management, rabbitmq_prometheus].
|
||||
6
docker/rabbitmq/conf/rabbitmq.conf
Normal file
6
docker/rabbitmq/conf/rabbitmq.conf
Normal file
@@ -0,0 +1,6 @@
|
||||
# 管理插件加载 definitions(仅空库时生效)
|
||||
management.load_definitions = /etc/rabbitmq/definitions.json
|
||||
|
||||
# (可选)禁用管理老式统计采集,转 Prometheus,避免弃用告警
|
||||
management_agent.disable_metrics_collector = true
|
||||
management.disable_stats = true
|
||||
31
docker/rabbitmq/definitions.json
Normal file
31
docker/rabbitmq/definitions.json
Normal 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": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
187
frontend_nuxt/components/BaseItemGroup.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div
|
||||
ref="groupRef"
|
||||
class="base-item-group"
|
||||
:class="groupClass"
|
||||
:style="groupStyle"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in normalizedItems"
|
||||
:key="resolveKey(item, index)"
|
||||
class="base-item-group-item"
|
||||
:style="{ zIndex: getZIndex(index) }"
|
||||
>
|
||||
<slot name="item" :item="item" :index="index"></slot>
|
||||
</div>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: null,
|
||||
},
|
||||
overlap: {
|
||||
type: [Number, String],
|
||||
default: 12,
|
||||
},
|
||||
expandedGap: {
|
||||
type: [Number, String],
|
||||
default: 8,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
animationDuration: {
|
||||
type: [Number, String],
|
||||
default: 200,
|
||||
},
|
||||
})
|
||||
|
||||
const groupRef = ref(null)
|
||||
const state = reactive({
|
||||
hovering: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const normalizedItems = computed(() => props.items || [])
|
||||
|
||||
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
|
||||
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
|
||||
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
|
||||
|
||||
const groupClass = computed(() => [
|
||||
`base-item-group--${props.direction}`,
|
||||
{
|
||||
'is-expanded': isExpanded.value,
|
||||
'is-reversed': props.reverse,
|
||||
},
|
||||
])
|
||||
|
||||
const groupStyle = computed(() => ({
|
||||
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
|
||||
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
|
||||
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
|
||||
}))
|
||||
|
||||
const isExpanded = computed(() => state.hovering || state.focused)
|
||||
|
||||
function onMouseEnter() {
|
||||
state.hovering = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
state.hovering = false
|
||||
}
|
||||
|
||||
function onFocusIn() {
|
||||
state.focused = true
|
||||
}
|
||||
|
||||
function onFocusOut(event) {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (!groupRef.value) {
|
||||
state.focused = false
|
||||
return
|
||||
}
|
||||
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
|
||||
state.focused = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKey(item, index) {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
|
||||
return item[props.itemKey]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function getZIndex(index) {
|
||||
if (props.reverse) {
|
||||
return index + 1
|
||||
}
|
||||
return normalizedItems.value.length - index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-item-group {
|
||||
--base-item-group-overlap: 12px;
|
||||
--base-item-group-expanded-gap: 8px;
|
||||
--base-item-group-transition-duration: 200ms;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.base-item-group:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-reversed {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.base-item-group--vertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-reversed {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.base-item-group-item {
|
||||
transition:
|
||||
margin var(--base-item-group-transition-duration) ease,
|
||||
transform var(--base-item-group-transition-duration) ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-left: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-left: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-top: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-top: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group.is-expanded .base-item-group-item {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
319
frontend_nuxt/components/DonateGroup.vue
Normal file
319
frontend_nuxt/components/DonateGroup.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="donate-container">
|
||||
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
||||
<div class="donate-viewer" @click="openPanel">
|
||||
<div
|
||||
class="donate-viewer-item-container"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<BaseItemGroup
|
||||
:items="donationList"
|
||||
:overlap="10"
|
||||
:expanded-gap="2"
|
||||
:direction="vertical"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<BaseUserAvatar
|
||||
:user-id="item.userId"
|
||||
:src="item.avatar"
|
||||
:alt="item.username"
|
||||
:width="20"
|
||||
:disable-link="true"
|
||||
/>
|
||||
</template>
|
||||
</BaseItemGroup>
|
||||
<div class="donate-counts-text">{{ totalAmount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<ToolTip content="赞赏作者" placement="bottom" v-else>
|
||||
<div class="donate-viewer-item placeholder" @click="openPanel">
|
||||
<financing class="donate-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="donate-panel"
|
||||
ref="donatePanelRef"
|
||||
:style="panelInlineStyle"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<div
|
||||
v-for="option in donateOptions"
|
||||
:key="option"
|
||||
class="donate-option"
|
||||
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
||||
@click="handleDonate(option)"
|
||||
>
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">{{ option }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Finance } from '@icon-park/vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
|
||||
const financing = Finance
|
||||
|
||||
const props = defineProps({
|
||||
postId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
authorId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
isAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const panelVisible = ref(false)
|
||||
const donatePanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
||||
const donating = ref(false)
|
||||
let hideTimer = null
|
||||
|
||||
const donateOptions = [10, 30, 100]
|
||||
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
||||
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
||||
const isAuthorUser = computed(() => {
|
||||
if (props.isAuthor) return true
|
||||
if (!authState.userId || !props.authorId) return false
|
||||
return Number(authState.userId) === Number(props.authorId)
|
||||
})
|
||||
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
panelVisible.value = true
|
||||
}
|
||||
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
panelVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const updatePanelInlineStyle = () => {
|
||||
if (!panelVisible.value) return
|
||||
const panelEl = donatePanelRef.value
|
||||
if (!panelEl) return
|
||||
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
|
||||
if (!parentEl) return
|
||||
const parentWidth = parentEl.clientWidth - 20
|
||||
panelInlineStyle.value = {
|
||||
width: 'max-content',
|
||||
maxWidth: `${parentWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(panelVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeSummary = (data) => ({
|
||||
totalAmount: data?.totalAmount ?? 0,
|
||||
donations: Array.isArray(data?.donations) ? data.donations : [],
|
||||
})
|
||||
|
||||
const loadDonations = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
} catch (e) {
|
||||
// ignore network errors for donation summary
|
||||
}
|
||||
}
|
||||
|
||||
const handleDonate = async (amount) => {
|
||||
if (!amount || donating.value) return
|
||||
if (!authState.loggedIn) {
|
||||
toast.error('请先登录后再打赏')
|
||||
panelVisible.value = false
|
||||
return
|
||||
}
|
||||
if (isAuthorUser.value) {
|
||||
toast.warning('不能给自己打赏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
donating.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
toast.error('请先登录后再打赏')
|
||||
} else {
|
||||
toast.error(data?.error || '打赏失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
toast.success('打赏成功,感谢你的支持!')
|
||||
panelVisible.value = false
|
||||
} catch (e) {
|
||||
toast.error('打赏失败,请稍后再试')
|
||||
} finally {
|
||||
donating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
await loadDonations()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.postId,
|
||||
async () => {
|
||||
donationSummary.value = { totalAmount: 0, donations: [] }
|
||||
await loadDonations()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.donate-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.donate-viewer-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-viewer {
|
||||
border-radius: 13px;
|
||||
padding: 3px;
|
||||
padding-right: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.donate-viewer:hover {
|
||||
background-color: var(--secondary-color-hover);
|
||||
}
|
||||
|
||||
.donate-counts-text {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.donate-panel {
|
||||
position: absolute;
|
||||
bottom: 35px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
z-index: 10;
|
||||
gap: 5px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.donate-viewer-item.placeholder {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
flex-direction: row;
|
||||
padding: 2px 10px;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-viewer-item {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.donate-viewer-item-placeholder-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.donate-option {
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-option:hover {
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-option.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.donate-option.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.donate-option-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.donate-viewer-item.placeholder {
|
||||
padding: 4px 8px;
|
||||
gap: 3px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 3px;
|
||||
margin-bottom: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg t="1755789348718" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13787" width="400" height="400"><path d="M152.773168 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288198 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288198 56.288199h-45.030559c-37.525466 0-56.281839-18.762733-56.281839-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13788"></path><path d="M409.294708 763.229814h228.968944v146.285714c0 63.22723-51.263602 114.484472-114.484472 114.484472-63.23359 0-114.484472-51.257242-114.484472-114.484472v-146.285714z" fill="#C5AC95" p-id="13789"></path><path d="M73.97605 520.357366c0 55.957466 45.361292 101.318758 101.318757 101.318758 55.951106 0 101.312398-45.361292 101.312398-101.318758 0-55.951106-45.361292-101.312398-101.318758-101.312397-55.951106 0-101.312398 45.361292-101.312397 101.318758z" fill="#C9AB90" p-id="13790"></path><path d="M490.48964 2.531379c186.520646 0 337.710112 151.195826 337.710112 337.716472v382.740671c0 99.474286-80.63523 180.109516-180.109516 180.109515H287.858484c-74.599354 0-135.078957-60.485963-135.078956-135.085317V340.247851C152.773168 153.727205 303.968994 2.531379 490.48964 2.531379z" fill="#EBD3BD" p-id="13791"></path><path d="M400.434882 509.099727c124.342857 0 225.140075 93.241242 225.140075 208.259975 0 5.679702-0.25441 11.308522-0.731429 16.880099H176.019876a195.278708 195.278708 0 0 1-0.731429-16.880099c0-115.018733 100.797217-208.259975 225.146435-208.259975zM805.684472 306.474932h45.030559c37.525466 0 56.288199 18.762733 56.288199 56.288198v45.024199c0 37.525466-18.762733 56.288199-56.288199 56.288199h-45.030559c-37.525466 0-56.288199-18.762733-56.288199-56.288199v-45.030559c0-37.525466 18.762733-56.288199 56.288199-56.288199z" fill="#4D4132" p-id="13792"></path><path d="M749.402634 520.357366c0 55.957466 45.361292 101.318758 101.312397 101.318758s101.318758-45.361292 101.318758-101.318758c0-55.951106-45.367652-101.312398-101.318758-101.312397s-101.318758 45.361292-101.318758 101.318758z" fill="#EBD3BD" p-id="13793"></path><path d="M805.684472 509.099727a45.030559 45.030559 0 1 0 90.061118 0.01908 45.030559 45.030559 0 0 0-90.061118-0.01908z" fill="#E89E80" p-id="13794"></path><path d="M175.288447 374.01441a90.061118 90.061118 0 1 0 180.115876 0c0-49.737143-40.323975-90.054758-90.061118-90.054758s-90.054758 40.323975-90.054758 90.061118z" fill="#FFFFFF" p-id="13795"></path><path d="M220.319006 379.64323a39.401739 39.401739 0 1 0 78.803478 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13796"></path><path d="M490.48964 374.01441c0 49.737143 40.323975 90.061118 90.061118 90.061118s90.048398-40.323975 90.048397-90.061118-40.317615-90.054758-90.054757-90.054758-90.061118 40.323975-90.061118 90.061118z" fill="#FFFFFF" p-id="13797"></path><path d="M535.520199 379.64323a39.401739 39.401739 0 1 0 78.797118 0 39.401739 39.401739 0 0 0-78.803478 0z" fill="#514141" p-id="13798"></path><path d="M394.806062 362.75677a40.18405 40.18405 0 0 1 37.754435 26.458634l41.99036 115.47031A78.803478 78.803478 0 0 1 400.504845 610.412124h-17.789615a78.803478 78.803478 0 0 1-72.920249-108.633043l46.207205-112.970733a41.920398 41.920398 0 0 1 38.797516-26.051578z" fill="#E89E80" p-id="13799"></path><path d="M165.36646 190.807453m38.16149 0l101.763975 0q38.161491 0 38.161491 38.161491l0 0q0 38.161491-38.161491 38.161491l-101.763975 0q-38.161491 0-38.16149-38.161491l0 0q0-38.161491 38.16149-38.161491Z" fill="#4D4132" p-id="13800"></path><path d="M483.378882 190.807453m38.161491 0l127.204969 0q38.161491 0 38.16149 38.161491l0 0q0 38.161491-38.16149 38.161491l-127.204969 0q-38.161491 0-38.161491-38.161491l0 0q0-38.161491 38.161491-38.161491Z" fill="#4D4132" p-id="13801"></path></svg>
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,33 +1,28 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
130
nginx/openisle
Normal 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
133
nginx/openisle-staging
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.", "://")
|
||||
));
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
# 所有环境变量已集中在仓库根目录的 .env.*.example 文件。
|
||||
# 如需在独立环境中运行,可参考以下字段:
|
||||
SERVER_PORT=<your-server-port>
|
||||
|
||||
# RabbitMQ 配置
|
||||
|
||||
Reference in New Issue
Block a user