Compare commits
131 Commits
codex/modi
...
codex/adap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb531d1337 | ||
|
|
cfe24b5e8e | ||
|
|
52633c8073 | ||
|
|
4802c78156 | ||
|
|
cf2299f9bf | ||
|
|
f03bf92641 | ||
|
|
8bb9c3e3d9 | ||
|
|
8c554465f6 | ||
|
|
05d56df44e | ||
|
|
5b0cbe8ce9 | ||
|
|
140d33d024 | ||
|
|
6ad7e951fe | ||
|
|
da47d37dc5 | ||
|
|
6293f572d8 | ||
|
|
94f4792a32 | ||
|
|
069f4bb8c1 | ||
|
|
7421ec8984 | ||
|
|
90b9d75da2 | ||
|
|
d69b094a7b | ||
|
|
67d80a4edd | ||
|
|
78498c0ac3 | ||
|
|
47c997ad22 | ||
|
|
2cd220e8eb | ||
|
|
8023fa1810 | ||
|
|
04b1b32b9c | ||
|
|
f5d8f37f96 | ||
|
|
4a4c256568 | ||
|
|
3bb14ca6a3 | ||
|
|
4ed679c4f4 | ||
|
|
50848e0da1 | ||
|
|
51819913a0 | ||
|
|
741bd115d5 | ||
|
|
d13ee2257f | ||
|
|
06dea47bec | ||
|
|
f89a17f14d | ||
|
|
ac433d6a45 | ||
|
|
62e7795e11 | ||
|
|
722d784691 | ||
|
|
5dab838482 | ||
|
|
67636475aa | ||
|
|
92ae8ae155 | ||
|
|
c0afe9e2a9 | ||
|
|
2c1bef4551 | ||
|
|
202c0f7b59 | ||
|
|
fdd6587fff | ||
|
|
77ea208961 | ||
|
|
96e1259ad7 | ||
|
|
b77b629d9e | ||
|
|
2e2813bcbd | ||
|
|
ad079e6bfd | ||
|
|
47a72dc9b0 | ||
|
|
70a83cbe06 | ||
|
|
0ff6f13c86 | ||
|
|
6f30cf0bc2 | ||
|
|
931aee4c3f | ||
|
|
8895405606 | ||
|
|
12b697d9dd | ||
|
|
49a55bcc36 | ||
|
|
690aae3577 | ||
|
|
93d2c39f6e | ||
|
|
99b824d852 | ||
|
|
67fae4129f | ||
|
|
3739286cca | ||
|
|
ec76e70ad0 | ||
|
|
f482d9ff9d | ||
|
|
5e13b4bdd3 | ||
|
|
78a65c6afe | ||
|
|
84236b0174 | ||
|
|
c337195b16 | ||
|
|
c506aec506 | ||
|
|
aa4274052e | ||
|
|
e96ba3c26f | ||
|
|
36758624c2 | ||
|
|
4427eff78a | ||
|
|
ab85e67d69 | ||
|
|
d7f6bb507d | ||
|
|
bced7807ae | ||
|
|
73bb873bfe | ||
|
|
564ebfbc2c | ||
|
|
9a42b8f32a | ||
|
|
513b1f45a1 | ||
|
|
1b204345a6 | ||
|
|
d146bf2b0d | ||
|
|
864a760b20 | ||
|
|
2ccdc21568 | ||
|
|
ff63d232a9 | ||
|
|
32a624e62d | ||
|
|
5af0c9dee0 | ||
|
|
edaafdd000 | ||
|
|
24838ab714 | ||
|
|
56a80a184b | ||
|
|
ed24ed174b | ||
|
|
3080acb6e4 | ||
|
|
1856eb191b | ||
|
|
0c2a50d620 | ||
|
|
7562de11a5 | ||
|
|
aaacf4efb1 | ||
|
|
1f30cdfe85 | ||
|
|
8b37cf5abb | ||
|
|
4af19a75c9 | ||
|
|
37ea986389 | ||
|
|
fefd0b3b6c | ||
|
|
a31ed29cfa | ||
|
|
2719819ad7 | ||
|
|
27ff9a9c9b | ||
|
|
18fde1052f | ||
|
|
800970f078 | ||
|
|
cbbd1440a1 | ||
|
|
215616d771 | ||
|
|
575e90e558 | ||
|
|
e63d66806d | ||
|
|
1fc0118c5a | ||
|
|
f3512c1184 | ||
|
|
28842c90b1 | ||
|
|
d67cc326c4 | ||
|
|
27c217a630 | ||
|
|
4e3e5f147c | ||
|
|
8767aa31d6 | ||
|
|
a428f472f2 | ||
|
|
8544803e62 | ||
|
|
54874cea7a | ||
|
|
098d82a6a0 | ||
|
|
90eee03198 | ||
|
|
3f152906f2 | ||
|
|
ef71d0b3d4 | ||
|
|
6f80d139ba | ||
|
|
7454931fa5 | ||
|
|
0852664a82 | ||
|
|
b1998be425 | ||
|
|
72adc5b232 | ||
|
|
d24e67de5d |
29
.gitignore
vendored
@@ -1,7 +1,30 @@
|
||||
# IDE
|
||||
.idea
|
||||
target
|
||||
openisle.iml
|
||||
|
||||
# log
|
||||
logs
|
||||
|
||||
# deps
|
||||
node_modules
|
||||
|
||||
# test & build
|
||||
coverage
|
||||
out/
|
||||
build
|
||||
dist
|
||||
open-isle.env
|
||||
logs
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env
|
||||
*.env
|
||||
.env*.local
|
||||
|
||||
# others
|
||||
openisle.iml
|
||||
|
||||
232
CONTRIBUTING.md
@@ -1,111 +1,185 @@
|
||||
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
|
||||
- [前置工作](#前置工作)
|
||||
- [启动后端服务](#启动后端服务)
|
||||
- [本地 IDEA](#本地-idea)
|
||||
- [配置环境变量](#配置环境变量)
|
||||
- [配置 IDEA 参数](#配置-idea-参数)
|
||||
- [配置 MySQL](#配置-mysql)
|
||||
- [Docker 环境](#docker-环境)
|
||||
- [配置环境变量](#配置环境变量-1)
|
||||
- [构建并启动镜像](#构建并启动镜像)
|
||||
- [启动前端服务](#启动前端服务)
|
||||
- [配置环境变量](#配置环境变量-2)
|
||||
- [安装依赖和运行](#安装依赖和运行)
|
||||
- [其他配置](#其他配置)
|
||||
|
||||
## 如何部署
|
||||
## 前置工作
|
||||
|
||||
> Step1 先克隆仓库
|
||||
先克隆仓库:
|
||||
|
||||
```shell
|
||||
git clone https://github.com/nagisa77/OpenIsle.git
|
||||
cd OpenIsle
|
||||
```
|
||||
|
||||
> Step2 后端部署
|
||||
- 后端开发环境
|
||||
- JDK 17+
|
||||
- 前端开发环境
|
||||
- Node.JS 20+
|
||||
|
||||
## 启动后端服务
|
||||
|
||||
启动后端服务有多种方式,选择一种即可。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 仅想修改前端的朋友可不用部署后端服务。转到 [启动前端服务](#启动前端服务) 章节。
|
||||
|
||||
### 本地 IDEA
|
||||
|
||||
```shell
|
||||
cd backend
|
||||
cd backend/
|
||||
```
|
||||
|
||||
以IDEA编辑器为例,IDEA打开backend文件夹。
|
||||
IDEA 打开 `backend/` 文件夹。
|
||||
|
||||
- 设置VM Option,最好运行在其他端口,非8080,这里设置8081
|
||||
#### 配置环境变量
|
||||
|
||||
1. 生成环境变量文件
|
||||
|
||||
```shell
|
||||
cp open-isle.env.example open-isle.env
|
||||
```
|
||||
|
||||
`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 追踪。
|
||||
|
||||

|
||||
|
||||
#### 配置 IDEA 参数
|
||||
|
||||
- 设置 JDK 版本为 java 17
|
||||
- 设置 VM Option,最好运行在其他端口,非 `8080`,这里设置 `8081`
|
||||
|
||||
```shell
|
||||
-Dserver.port=8081
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### 配置 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) 脚本,导入基本的数据
|
||||
|
||||

|
||||
|
||||
4. 处理完环境问题直接跑起来就能通了
|
||||
|
||||

|
||||
|
||||
### Docker 环境
|
||||
#### 配置环境变量
|
||||
|
||||
```shell
|
||||
-Dserver.port=8081
|
||||
cd docker/
|
||||
```
|
||||
|
||||

|
||||
主要配置两个 `.env` 文件
|
||||
|
||||
- 设置jdk版本为java 17
|
||||
- `backend/open-isle.env`:后端环境变量,配置同上,见 [配置环境变量](#配置环境变量)。
|
||||
- `docker/.env`:Docker Compose 环境变量,主要配置 MySQL 相关
|
||||
```shell
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||

|
||||
> [!TIP]
|
||||
> 使用单独的 `.env` 文件是为了兼容线上环境或已启用 MySQL 服务的情况,如果只是想快速体验或者启动统一的环境,则推荐使用本方式。
|
||||
|
||||
- 本机配置MySQL服务(网上很多教程,忽略)
|
||||
- 设置环境变量.env 文件 或.properties 文件(二选一)
|
||||
在指定 `docker/.env` 后,`backend/open-isle.env` 中以下配置会被覆盖,这样就确保使用了同一份配置。
|
||||
|
||||
1. 环境变量文件生成
|
||||
```ini
|
||||
MYSQL_URL=
|
||||
MYSQL_USER=
|
||||
MYSQL_PASSWORD=
|
||||
```
|
||||
|
||||
#### 构建并启动镜像
|
||||
|
||||
```shell
|
||||
cp open-isle.env.example open-isle.env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
修改环境变量,留下需要的,比如你要开发Google登录业务,就需要谷歌相关的变量,数据库是一定要的
|
||||
|
||||

|
||||
|
||||
应用环境文件, 选择刚刚的`open-isle.env`
|
||||
|
||||

|
||||
|
||||
2. 直接修改 .properities 文件
|
||||
|
||||
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
|
||||
|
||||

|
||||
|
||||
处理完环境问题直接跑起来就能通了
|
||||
|
||||

|
||||
|
||||
> Step3 前端部署
|
||||
|
||||
**⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
|
||||
如果想了解启动过程发生了什么可以查看日志
|
||||
|
||||
```shell
|
||||
cd ../frontend_nuxt/
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
copy环境.env文件
|
||||
## 启动前端服务
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
||||
|
||||
```shell
|
||||
cp .env.staging.example .env
|
||||
cd frontend_nuxt/
|
||||
```
|
||||
|
||||
1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
|
||||
### 配置环境变量
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
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://www.open-isle.com
|
||||
```
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口。
|
||||
|
||||
2. 依赖预发环境后台环境
|
||||
- 利用预发环境:**(⚠️ 强烈推荐只开发前端的朋友使用该环境)**
|
||||
|
||||
**(⚠️强烈推荐只部署前端的朋友使用该环境)**
|
||||
```shell
|
||||
cp .env.staging.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
; 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://www.open-isle.com
|
||||
```
|
||||
- 利用生产环境
|
||||
|
||||
4. 依赖线上后台环境
|
||||
```shell
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
; 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://www.open-isle.com
|
||||
```
|
||||
- 利用本地环境
|
||||
|
||||
```shell
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
### 安装依赖和运行
|
||||
|
||||
前端安装依赖并启动服务。
|
||||
|
||||
```shell
|
||||
# 安装依赖
|
||||
@@ -115,4 +189,22 @@ npm install --verbose
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。
|
||||
|
||||
## 其他配置
|
||||
|
||||
配置第三方登录,这里以 GitHub 为例:
|
||||
|
||||
- 修改 `application.properties` 配置
|
||||
|
||||

|
||||
|
||||
- 修改 `.env` 配置
|
||||
|
||||

|
||||
|
||||
- 配置第三方登录回调地址
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
BIN
assets/contributing/backend_img.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
assets/contributing/backend_img_2.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
assets/contributing/backend_img_3.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/contributing/backend_img_4.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
assets/contributing/backend_img_5.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
assets/contributing/backend_img_6.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
assets/contributing/backend_img_7.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/contributing/fontend_img.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/contributing/github_img.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
assets/contributing/github_img_2.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
assets/contributing/resources_img.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
@@ -1,5 +1,8 @@
|
||||
# === Spring Boot ===
|
||||
SERVER_PORT=8080
|
||||
|
||||
# === Database ===
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_URL=jdbc:mysql://<数据库地址>:<数据库端口>/<数据库名>?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
|
||||
@@ -28,6 +31,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
|
||||
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
||||
DISCORD_CLIENT_ID=<你的discord-client-id>
|
||||
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
||||
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
|
||||
|
||||
# === OPENAI ===
|
||||
OPENAI_API_KEY=<你的openai-api-key>
|
||||
@@ -36,4 +40,10 @@ OPENAI_API_KEY=<你的openai-api-key>
|
||||
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
|
||||
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
|
||||
|
||||
# === RabbitMQ ===
|
||||
RABBITMQ_HOST=<你的rabbitmq_host>
|
||||
RABBITMQ_PORT=<你的rabbitmq_port>
|
||||
RABBITMQ_USERNAME=<你的rabbitmq_username>
|
||||
RABBITMQ_PASSWORD=<你的rabbitmq_password>
|
||||
|
||||
# LOG_LEVEL=DEBUG
|
||||
|
||||
@@ -26,9 +26,22 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-hibernate6</artifactId>
|
||||
<version>2.20.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
@@ -114,6 +127,11 @@
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
<version>1.70</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
|
||||
<version>2.2.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
@@ -141,6 +159,26 @@
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<!-- https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator-maven-plugin/README.md -->
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-maven-plugin</artifactId>
|
||||
<version>1.4</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>generate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<!-- 此处为硬编码,应优化为 env 的配置 -->
|
||||
<apiDocsUrl>http://localhost:8080/api/v3/api-docs</apiDocsUrl>
|
||||
<outputFileName>openapi.json</outputFileName>
|
||||
<outputDir>${project.build.directory}</outputDir>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
109
backend/src/main/java/com/openisle/config/CachingConfig.java
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
|
||||
import com.fasterxml.jackson.datatype.hibernate6.Hibernate6Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 缓存配置类
|
||||
* @author smallclover
|
||||
* @since 2025-09-04
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CachingConfig {
|
||||
|
||||
// 标签缓存名
|
||||
public static final String TAG_CACHE_NAME="openisle_tags";
|
||||
// 分类缓存名
|
||||
public static final String CATEGORY_CACHE_NAME="openisle_categories";
|
||||
|
||||
/**
|
||||
* 自定义Redis的序列化器
|
||||
* @return
|
||||
*/
|
||||
@Bean()
|
||||
@Primary
|
||||
public RedisSerializer<Object> redisSerializer() {
|
||||
// 注册 JavaTimeModule 來支持 Java 8 的日期和时间 API,否则回报一下错误,同时还要引入jsr310
|
||||
|
||||
// org.springframework.data.redis.serializer.SerializationException: Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported by default:
|
||||
// add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
|
||||
// (through reference chain: java.util.ArrayList[0]->com.openisle.dto.TagDto["createdAt"])
|
||||
// 设置可见性,允许序列化所有元素
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
// Hibernate6Module 可以自动处理懒加载代理对象。
|
||||
// Tag对象的creator是FetchType.LAZY
|
||||
objectMapper.registerModule(new Hibernate6Module()
|
||||
.disable(Hibernate6Module.Feature.USE_TRANSIENT_ANNOTATION));
|
||||
// service的时候带上类型信息
|
||||
// 启用类型信息,避免 LinkedHashMap 问题
|
||||
objectMapper.activateDefaultTyping(
|
||||
LaissezFaireSubTypeValidator.instance,
|
||||
ObjectMapper.DefaultTyping.NON_FINAL,
|
||||
JsonTypeInfo.As.PROPERTY
|
||||
);
|
||||
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
|
||||
return new GenericJackson2JsonRedisSerializer(objectMapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 Spring Cache 使用 RedisCacheManager
|
||||
*/
|
||||
@Bean
|
||||
public CacheManager cacheManager(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
||||
|
||||
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
|
||||
.entryTtl(Duration.ZERO) // 默认缓存不过期
|
||||
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
|
||||
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
|
||||
.disableCachingNullValues(); // 禁止缓存 null 值
|
||||
// 个别缓存单独设置TTL时间
|
||||
// Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
|
||||
// cacheConfigs.put("openisle_tags", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO));
|
||||
// cacheConfigs.put("openisle_categories", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ZERO));
|
||||
|
||||
return RedisCacheManager.builder(connectionFactory)
|
||||
.cacheDefaults(config)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 RedisTemplate,支持直接操作 Redis
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory, RedisSerializer<Object> redisSerializer) {
|
||||
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// key 和 hashKey 使用 String 序列化
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// value 和 hashValue 使用 JSON 序列化
|
||||
template.setValueSerializer(redisSerializer);
|
||||
template.setHashValueSerializer(redisSerializer);
|
||||
|
||||
return template;
|
||||
}
|
||||
}
|
||||
48
backend/src/main/java/com/openisle/config/OpenApiConfig.java
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
|
||||
@Value("${springdoc.info.title}")
|
||||
private String title;
|
||||
|
||||
@Value("${springdoc.info.description}")
|
||||
private String description;
|
||||
|
||||
@Value("${springdoc.info.version}")
|
||||
private String version;
|
||||
|
||||
@Value("${springdoc.info.scheme}")
|
||||
private String scheme;
|
||||
|
||||
@Value("${springdoc.info.header}")
|
||||
private String header;
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
SecurityScheme securityScheme = new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme(scheme.toLowerCase())
|
||||
.bearerFormat("JWT")
|
||||
.in(SecurityScheme.In.HEADER)
|
||||
.name(header);
|
||||
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.version(version))
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("JWT", securityScheme))
|
||||
.addSecurityItem(new SecurityRequirement().addList("JWT"));
|
||||
}
|
||||
}
|
||||
204
backend/src/main/java/com/openisle/config/RabbitMQConfig.java
Normal file
@@ -0,0 +1,204 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.TopicExchange;
|
||||
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class RabbitMQConfig {
|
||||
|
||||
public static final String EXCHANGE_NAME = "openisle-exchange";
|
||||
// 保持向后兼容的常量
|
||||
public static final String QUEUE_NAME = "notifications-queue";
|
||||
public static final String ROUTING_KEY = "notifications.routingkey";
|
||||
|
||||
// 硬编码为16以匹配ShardingStrategy中的十六进制分片逻辑
|
||||
private final int queueCount = 16;
|
||||
|
||||
@Value("${rabbitmq.queue.durable}")
|
||||
private boolean queueDurable;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
System.out.println("RabbitMQ配置初始化: 队列数量=" + queueCount + ", 持久化=" + queueDurable);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TopicExchange exchange() {
|
||||
return new TopicExchange(EXCHANGE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有分片队列, 使用十六进制后缀 (0-f)
|
||||
*/
|
||||
@Bean
|
||||
public List<Queue> shardedQueues() {
|
||||
System.out.println("开始创建分片队列 Bean...");
|
||||
|
||||
List<Queue> queues = new ArrayList<>();
|
||||
for (int i = 0; i < queueCount; i++) {
|
||||
String shardKey = Integer.toHexString(i);
|
||||
String queueName = "notifications-queue-" + shardKey;
|
||||
Queue queue = new Queue(queueName, queueDurable);
|
||||
queues.add(queue);
|
||||
}
|
||||
|
||||
System.out.println("分片队列 Bean 创建完成,总数: " + queues.size());
|
||||
return queues;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有分片绑定, 使用十六进制路由键 (notifications.shard.0 - notifications.shard.f)
|
||||
*/
|
||||
@Bean
|
||||
public List<Binding> shardedBindings(TopicExchange exchange, @Qualifier("shardedQueues") List<Queue> shardedQueues) {
|
||||
System.out.println("开始创建分片绑定 Bean...");
|
||||
List<Binding> bindings = new ArrayList<>();
|
||||
if (shardedQueues != null) {
|
||||
for (Queue queue : shardedQueues) {
|
||||
String queueName = queue.getName();
|
||||
String shardKey = queueName.substring("notifications-queue-".length());
|
||||
String routingKey = "notifications.shard." + shardKey;
|
||||
Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey);
|
||||
bindings.add(binding);
|
||||
}
|
||||
}
|
||||
|
||||
System.out.println("分片绑定 Bean 创建完成,总数: " + bindings.size());
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保持向后兼容的单队列配置(可选)
|
||||
*/
|
||||
@Bean
|
||||
public Queue legacyQueue() {
|
||||
return new Queue(QUEUE_NAME, queueDurable);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保持向后兼容的单队列绑定(可选)
|
||||
*/
|
||||
@Bean
|
||||
public Binding legacyBinding(Queue legacyQueue, TopicExchange exchange) {
|
||||
return BindingBuilder.bind(legacyQueue).to(exchange).with(ROUTING_KEY);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Jackson2JsonMessageConverter messageConverter() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||
objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
return new Jackson2JsonMessageConverter(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {
|
||||
return new RabbitAdmin(connectionFactory);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
|
||||
RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||
template.setMessageConverter(messageConverter());
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 CommandLineRunner 确保在应用完全启动后声明队列到 RabbitMQ
|
||||
* 这样可以确保 RabbitAdmin 和所有 Bean 都已正确初始化
|
||||
*/
|
||||
@Bean
|
||||
@DependsOn({"rabbitAdmin", "shardedQueues", "exchange"})
|
||||
public CommandLineRunner queueDeclarationRunner(RabbitAdmin rabbitAdmin,
|
||||
@Qualifier("shardedQueues") List<Queue> shardedQueues,
|
||||
TopicExchange exchange,
|
||||
Queue legacyQueue,
|
||||
@Qualifier("shardedBindings") List<Binding> shardedBindings,
|
||||
Binding legacyBinding) {
|
||||
return args -> {
|
||||
System.out.println("=== 开始主动声明 RabbitMQ 组件 ===");
|
||||
|
||||
try {
|
||||
// 声明交换
|
||||
rabbitAdmin.declareExchange(exchange);
|
||||
|
||||
// 声明分片队列 - 检查存在性
|
||||
System.out.println("开始检查并声明 " + shardedQueues.size() + " 个分片队列...");
|
||||
int successCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Queue queue : shardedQueues) {
|
||||
String queueName = queue.getName();
|
||||
try {
|
||||
// 使用 declareQueue 的返回值判断队列是否已存在
|
||||
// 如果队列已存在且配置匹配,declareQueue 会返回现有队列信息
|
||||
// 如果不匹配或不存在,会创建新队列
|
||||
rabbitAdmin.declareQueue(queue);
|
||||
successCount++;
|
||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
||||
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
|
||||
skippedCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("队列声明失败: " + queueName + ", 错误: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
System.out.println("分片队列处理完成: 成功 " + successCount + ", 跳过 " + skippedCount + ", 总数 " + shardedQueues.size());
|
||||
|
||||
// 声明分片绑定
|
||||
System.out.println("开始声明 " + shardedBindings.size() + " 个分片绑定...");
|
||||
int bindingSuccessCount = 0;
|
||||
for (Binding binding : shardedBindings) {
|
||||
try {
|
||||
rabbitAdmin.declareBinding(binding);
|
||||
bindingSuccessCount++;
|
||||
} catch (Exception e) {
|
||||
System.err.println("绑定声明失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
System.out.println("分片绑定声明完成: 成功 " + bindingSuccessCount + "/" + shardedBindings.size());
|
||||
|
||||
// 声明遗留队列和绑定 - 检查存在性
|
||||
try {
|
||||
rabbitAdmin.declareQueue(legacyQueue);
|
||||
rabbitAdmin.declareBinding(legacyBinding);
|
||||
System.out.println("遗留队列和绑定就绪: " + QUEUE_NAME + " (已存在或新创建)");
|
||||
} catch (org.springframework.amqp.AmqpIOException e) {
|
||||
if (e.getMessage().contains("PRECONDITION_FAILED") && e.getMessage().contains("durable")) {
|
||||
System.out.println("遗留队列已存在但 durable 设置不匹配: " + QUEUE_NAME + ", 保持现有队列");
|
||||
} else {
|
||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("遗留队列声明失败: " + QUEUE_NAME + ", 错误: " + e.getMessage());
|
||||
}
|
||||
|
||||
System.out.println("=== RabbitMQ 组件声明完成 ===");
|
||||
System.out.println("请检查 RabbitMQ 管理界面确认队列已正确创建");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("RabbitMQ 组件声明过程中发生严重错误:");
|
||||
e.printStackTrace();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Logs a message when a Redis connection is successfully established.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class RedisConnectionLogger implements InitializingBean {
|
||||
|
||||
private final RedisConnectionFactory connectionFactory;
|
||||
|
||||
public RedisConnectionLogger(RedisConnectionFactory connectionFactory) {
|
||||
this.connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() {
|
||||
try (var connection = connectionFactory.getConnection()) {
|
||||
connection.ping();
|
||||
if (connectionFactory instanceof LettuceConnectionFactory lettuce) {
|
||||
log.info("Redis connection established at {}:{}", lettuce.getHostName(), lettuce.getPort());
|
||||
} else {
|
||||
log.info("Redis connection established");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to connect to Redis", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,14 @@ public class SecurityConfig {
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
cfg.setAllowedOrigins(List.of(
|
||||
"http://127.0.0.1:8080",
|
||||
"http://127.0.0.1:8081",
|
||||
"http://127.0.0.1:8082",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3001",
|
||||
"http://127.0.0.1",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:8081",
|
||||
"http://localhost:8082",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost",
|
||||
@@ -85,6 +89,7 @@ public class SecurityConfig {
|
||||
"http://30.211.97.238",
|
||||
"http://192.168.7.98",
|
||||
"http://192.168.7.98:3000",
|
||||
"https://petstore.swagger.io",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://")
|
||||
));
|
||||
@@ -106,6 +111,7 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
|
||||
.requestMatchers("/api/v3/api-docs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
@@ -176,7 +182,8 @@ public class SecurityConfig {
|
||||
return;
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet
|
||||
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) {
|
||||
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")
|
||||
&& !uri.startsWith("/api/v3/api-docs")) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\": \"Missing token\"}");
|
||||
|
||||
14
backend/src/main/java/com/openisle/config/ShardInfo.java
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class ShardInfo {
|
||||
private int shardIndex;
|
||||
private String queueName;
|
||||
private String routingKey;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class ShardingStrategy {
|
||||
|
||||
// 固定为16以匹配RabbitMQConfig中的十六进制分片逻辑
|
||||
private static final int QUEUE_COUNT = 16;
|
||||
|
||||
// 分片分布统计
|
||||
private final Map<Integer, AtomicLong> shardCounts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 根据用户名获取分片信息(基于哈希值首字符)
|
||||
*/
|
||||
public ShardInfo getShardInfo(String username) {
|
||||
if (username == null || username.isEmpty()) {
|
||||
// 空用户名默认分到第0个分片
|
||||
return getShardInfoByIndex(0);
|
||||
}
|
||||
|
||||
// 计算用户名的哈希值并转为十六进制字符串
|
||||
String hash = Integer.toHexString(Math.abs(username.hashCode()));
|
||||
|
||||
// 取哈希值的第一个字符 (0-9, a-f)
|
||||
char firstChar = hash.charAt(0);
|
||||
|
||||
// 十六进制字符映射到队列
|
||||
int shard = getShardFromHexChar(firstChar);
|
||||
recordShardUsage(shard);
|
||||
|
||||
log.debug("Username '{}' -> hash '{}' -> firstChar '{}' -> shard {}",
|
||||
username, hash, firstChar, shard);
|
||||
|
||||
return getShardInfoByIndex(shard);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将十六进制字符映射到分片索引
|
||||
*/
|
||||
private int getShardFromHexChar(char hexChar) {
|
||||
int charValue;
|
||||
if (hexChar >= '0' && hexChar <= '9') {
|
||||
charValue = hexChar - '0'; // 0-9
|
||||
} else if (hexChar >= 'a' && hexChar <= 'f') {
|
||||
charValue = hexChar - 'a' + 10; // 10-15
|
||||
} else {
|
||||
// 异常情况,默认为0
|
||||
charValue = 0;
|
||||
}
|
||||
|
||||
// 映射到队列数量范围内
|
||||
return charValue % QUEUE_COUNT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分片索引获取分片信息
|
||||
*/
|
||||
private ShardInfo getShardInfoByIndex(int shard) {
|
||||
String shardKey = Integer.toHexString(shard);
|
||||
return new ShardInfo(
|
||||
shard,
|
||||
"notifications-queue-" + shardKey,
|
||||
"notifications.shard." + shardKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录分片使用统计
|
||||
*/
|
||||
private void recordShardUsage(int shard) {
|
||||
shardCounts.computeIfAbsent(shard, k -> new AtomicLong(0)).incrementAndGet();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.service.JwtService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserDetailsService userDetailsService;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue"
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
// Set user destination prefix for personal messages
|
||||
config.setUserDestinationPrefix("/user");
|
||||
// Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods.
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// 1) 原生 WebSocket(不带 SockJS)
|
||||
registry.addEndpoint("/api/ws")
|
||||
.setAllowedOriginPatterns(
|
||||
"https://staging.open-isle.com",
|
||||
"https://www.staging.open-isle.com",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://"),
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.7.98:*",
|
||||
"http://30.211.97.238:*"
|
||||
);
|
||||
|
||||
// 2) SockJS 回退:单独路径
|
||||
registry.addEndpoint("/api/sockjs")
|
||||
.setAllowedOriginPatterns(
|
||||
"https://staging.open-isle.com",
|
||||
"https://www.staging.open-isle.com",
|
||||
websiteUrl,
|
||||
websiteUrl.replace("://www.", "://"),
|
||||
"http://localhost:*",
|
||||
"http://127.0.0.1:*",
|
||||
"http://192.168.7.98:*",
|
||||
"http://30.211.97.238:*"
|
||||
)
|
||||
.withSockJS()
|
||||
.setWebSocketEnabled(true)
|
||||
.setSessionCookieNeeded(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(new ChannelInterceptor() {
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
|
||||
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket CONNECT command received");
|
||||
String authHeader = accessor.getFirstNativeHeader("Authorization");
|
||||
System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
System.out.println("JWT validated for user: " + username);
|
||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
accessor.setUser(auth);
|
||||
System.out.println("WebSocket user set: " + username);
|
||||
} catch (Exception e) {
|
||||
System.err.println("JWT validation failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination());
|
||||
System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null"));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class AuthController {
|
||||
private final GithubAuthService githubAuthService;
|
||||
private final DiscordAuthService discordAuthService;
|
||||
private final TwitterAuthService twitterAuthService;
|
||||
private final TelegramAuthService telegramAuthService;
|
||||
private final RegisterModeService registerModeService;
|
||||
private final NotificationService notificationService;
|
||||
private final UserRepository userRepository;
|
||||
@@ -360,6 +361,51 @@ public class AuthController {
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/telegram")
|
||||
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
|
||||
req,
|
||||
registerModeService.getRegisterMode(),
|
||||
viaInvite);
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
));
|
||||
}
|
||||
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
if (!result.getUser().isApproved()) {
|
||||
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "IS_APPROVING",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Account awaiting approval",
|
||||
"reason_code", "NOT_APPROVED",
|
||||
"token", jwtService.generateReasonToken(result.getUser().getUsername())
|
||||
));
|
||||
}
|
||||
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
|
||||
}
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"error", "Invalid telegram data",
|
||||
"reason_code", "INVALID_CREDENTIALS"
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/check")
|
||||
public ResponseEntity<?> checkToken() {
|
||||
return ResponseEntity.ok(Map.of("valid", true));
|
||||
|
||||
@@ -62,4 +62,14 @@ public class NotificationController {
|
||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
|
||||
@GetMapping("/email-prefs")
|
||||
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
|
||||
return notificationService.listEmailPreferences(auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/email-prefs")
|
||||
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MessageNotificationPayload implements Serializable {
|
||||
private String targetUsername;
|
||||
private Object payload;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** Request for Telegram login. */
|
||||
@Data
|
||||
public class TelegramLoginRequest {
|
||||
private String id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String username;
|
||||
private String photoUrl;
|
||||
private Long authDate;
|
||||
private String hash;
|
||||
private String inviteToken;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.Where;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "comments")
|
||||
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||
@Where(clause = "deleted_at IS NULL")
|
||||
public class Comment {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -41,4 +45,7 @@ public class Comment {
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ public class InviteToken {
|
||||
@Id
|
||||
private String token;
|
||||
|
||||
/**
|
||||
* Short token used in invite links. Existing records may have this field null
|
||||
* and fall back to {@link #token} for backward compatibility.
|
||||
*/
|
||||
@Column(unique = true)
|
||||
private String shortToken;
|
||||
|
||||
@ManyToOne
|
||||
private User inviter;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -20,6 +21,7 @@ public class Message {
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
@JsonBackReference
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import com.fasterxml.jackson.annotation.JsonManagedReference;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -41,8 +43,10 @@ public class MessageConversation {
|
||||
private Message lastMessage;
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonBackReference
|
||||
private Set<MessageParticipant> participants = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@JsonBackReference
|
||||
private Set<Message> messages = new HashSet<>();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonBackReference;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -19,6 +20,7 @@ public class MessageParticipant {
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
@JsonBackReference
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
|
||||
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.Where;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_histories")
|
||||
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||
@Where(clause = "deleted_at IS NULL")
|
||||
public class PointHistory {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@@ -46,4 +50,7 @@ public class PointHistory {
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ public class Tag {
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
// 改用redis缓存之后选择立即加载策略
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "creator_id")
|
||||
private User creator;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ public class User {
|
||||
NotificationType.USER_ACTIVITY
|
||||
);
|
||||
|
||||
@ElementCollection(targetClass = NotificationType.class)
|
||||
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@Column(name = "notification_type")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
|
||||
@@ -9,4 +9,8 @@ import java.util.Optional;
|
||||
|
||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||
|
||||
Optional<InviteToken> findByShortToken(String shortToken);
|
||||
|
||||
boolean existsByShortToken(String shortToken);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
|
||||
|
||||
@Query("SELECT c FROM MessageConversation c LEFT JOIN FETCH c.participants p LEFT JOIN FETCH p.user WHERE c.id = :id")
|
||||
java.util.Optional<MessageConversation> findByIdWithParticipantsAndUsers(@Param("id") Long id);
|
||||
@Query("SELECT c FROM MessageConversation c " +
|
||||
"WHERE c.channel = false AND size(c.participants) = 2 " +
|
||||
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Comment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -12,4 +13,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
||||
long countByUser(User user);
|
||||
|
||||
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
|
||||
|
||||
List<PointHistory> findByComment(Comment comment);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Category;
|
||||
import com.openisle.repository.CategoryRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
@@ -11,7 +14,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class CategoryService {
|
||||
private final CategoryRepository categoryRepository;
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category createCategory(String name, String description, String icon, String smallIcon) {
|
||||
Category category = new Category();
|
||||
category.setName(name);
|
||||
@@ -20,7 +23,7 @@ public class CategoryService {
|
||||
category.setSmallIcon(smallIcon);
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public Category updateCategory(Long id, String name, String description, String icon, String smallIcon) {
|
||||
Category category = categoryRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
@@ -38,7 +41,7 @@ public class CategoryService {
|
||||
}
|
||||
return categoryRepository.save(category);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
|
||||
public void deleteCategory(Long id) {
|
||||
categoryRepository.deleteById(id);
|
||||
}
|
||||
@@ -48,6 +51,14 @@ public class CategoryService {
|
||||
.orElseThrow(() -> new IllegalArgumentException("Category not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 该方法每次首页加载都会访问,加入缓存
|
||||
* @return
|
||||
*/
|
||||
@Cacheable(
|
||||
value = CachingConfig.CATEGORY_CACHE_NAME,
|
||||
key = "'listCategories:'"
|
||||
)
|
||||
public List<Category> listCategories() {
|
||||
return categoryRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
@@ -11,8 +12,10 @@ import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.SubscriptionService;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -20,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -37,6 +43,8 @@ public class CommentService {
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
private final PointService pointService;
|
||||
private final ImageUploader imageUploader;
|
||||
|
||||
@Transactional
|
||||
@@ -63,16 +71,19 @@ public class CommentService {
|
||||
log.debug("Comment {} saved for post {}", comment.getId(), postId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(post.getAuthor().getId())) {
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(postId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
|
||||
null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, post, comment);
|
||||
@@ -109,21 +120,25 @@ public class CommentService {
|
||||
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
|
||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||
if (!author.getId().equals(parent.getAuthor().getId())) {
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
|
||||
comment, null, null, null, null);
|
||||
}
|
||||
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||
if (!u.getId().equals(author.getId())) {
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
|
||||
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
|
||||
null, null, null, null);
|
||||
}
|
||||
}
|
||||
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||
@@ -235,11 +250,33 @@ public class CommentService {
|
||||
for (Comment c : replies) {
|
||||
deleteCommentCascade(c);
|
||||
}
|
||||
|
||||
// 逻辑删除相关的积分历史记录,并收集受影响的用户
|
||||
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
|
||||
// 收集需要重新计算积分的用户
|
||||
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
|
||||
|
||||
// 删除其他相关数据
|
||||
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||
|
||||
// 逻辑删除评论
|
||||
commentRepository.delete(comment);
|
||||
// 删除积分历史
|
||||
pointHistoryRepository.deleteAll(pointHistories);
|
||||
|
||||
// 重新计算受影响用户的积分
|
||||
if (!usersToRecalculate.isEmpty()) {
|
||||
for (User user : usersToRecalculate) {
|
||||
int newPoints = pointService.recalculateUserPoints(user);
|
||||
user.setPoint(newPoints);
|
||||
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
|
||||
}
|
||||
userRepository.saveAll(usersToRecalculate);
|
||||
}
|
||||
|
||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,33 +30,53 @@ public class InviteService {
|
||||
LocalDate today = LocalDate.now();
|
||||
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||
if (existing.isPresent()) {
|
||||
return existing.get().getToken();
|
||||
InviteToken inviteToken = existing.get();
|
||||
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
|
||||
}
|
||||
|
||||
String token = jwtService.generateInviteToken(username);
|
||||
String shortToken;
|
||||
do {
|
||||
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
|
||||
} while (inviteTokenRepository.existsByShortToken(shortToken));
|
||||
|
||||
InviteToken inviteToken = new InviteToken();
|
||||
inviteToken.setToken(token);
|
||||
inviteToken.setShortToken(shortToken);
|
||||
inviteToken.setInviter(inviter);
|
||||
inviteToken.setCreatedDate(today);
|
||||
inviteToken.setUsageCount(0);
|
||||
inviteTokenRepository.save(inviteToken);
|
||||
return token;
|
||||
return shortToken;
|
||||
}
|
||||
|
||||
public InviteValidateResult validate(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
String realToken = token;
|
||||
if (invite == null) {
|
||||
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
|
||||
if (invite == null) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
realToken = invite.getToken();
|
||||
}
|
||||
|
||||
try {
|
||||
jwtService.validateAndGetSubjectForInvite(token);
|
||||
jwtService.validateAndGetSubjectForInvite(realToken);
|
||||
} catch (Exception e) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
|
||||
|
||||
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
|
||||
}
|
||||
|
||||
public void consume(String token, String newUserName) {
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||
InviteToken invite = inviteTokenRepository.findById(token)
|
||||
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
|
||||
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||
inviteTokenRepository.save(invite);
|
||||
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
|
||||
|
||||
@@ -16,16 +16,18 @@ import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.ReactionDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import com.openisle.mapper.ReactionMapper;
|
||||
import com.openisle.dto.MessageNotificationPayload;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -37,7 +39,7 @@ public class MessageService {
|
||||
private final MessageConversationRepository conversationRepository;
|
||||
private final MessageParticipantRepository participantRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final NotificationProducer notificationProducer;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final ReactionMapper reactionMapper;
|
||||
|
||||
@@ -69,26 +71,41 @@ public class MessageService {
|
||||
conversationRepository.save(conversation);
|
||||
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
|
||||
|
||||
// Broadcast the new message to subscribed clients
|
||||
MessageDto messageDto = toDto(message);
|
||||
String conversationDestination = "/topic/conversation/" + conversation.getId();
|
||||
messagingTemplate.convertAndSend(conversationDestination, messageDto);
|
||||
log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination);
|
||||
|
||||
// Also notify the recipient on their personal channel to update the conversation list
|
||||
String userDestination = "/topic/user/" + recipient.getId() + "/messages";
|
||||
messagingTemplate.convertAndSend(userDestination, messageDto);
|
||||
log.info("Message {} notification sent to destination: {}", message.getId(), userDestination);
|
||||
|
||||
// Notify recipient of new unread count
|
||||
long unreadCount = getUnreadMessageCount(recipientId);
|
||||
log.info("Calculating unread count for user {}: {}", recipientId, unreadCount);
|
||||
|
||||
// Send using username instead of user ID for WebSocket routing
|
||||
String recipientUsername = recipient.getUsername();
|
||||
messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount);
|
||||
log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count",
|
||||
unreadCount, recipientId, recipientUsername, recipientUsername);
|
||||
try {
|
||||
MessageDto messageDto = toDto(message);
|
||||
|
||||
long unreadCount = getUnreadMessageCount(recipientId);
|
||||
|
||||
// 创建包含对话和参与者信息的完整payload
|
||||
Map<String, Object> conversationInfo = new HashMap<>();
|
||||
conversationInfo.put("id", conversation.getId());
|
||||
conversationInfo.put("participants", conversation.getParticipants().stream()
|
||||
.map(p -> {
|
||||
Map<String, Object> participantInfo = new HashMap<>();
|
||||
participantInfo.put("userId", p.getUser().getId());
|
||||
participantInfo.put("username", p.getUser().getUsername());
|
||||
return participantInfo;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
Map<String, Object> combinedPayload = new HashMap<>();
|
||||
combinedPayload.put("message", messageDto);
|
||||
combinedPayload.put("unreadCount", unreadCount);
|
||||
combinedPayload.put("conversation", conversationInfo);
|
||||
combinedPayload.put("senderId", senderId);
|
||||
if (notificationProducer != null) {
|
||||
log.info("NotificationProducer is available");
|
||||
} else {
|
||||
log.info("ERROR: NotificationProducer is NULL!");
|
||||
return message;
|
||||
}
|
||||
log.info("Recipient username: {}", recipient.getUsername());
|
||||
|
||||
notificationProducer.sendNotification(new MessageNotificationPayload(recipient.getUsername(), combinedPayload));
|
||||
log.info("=== Notification call completed ===");
|
||||
} catch (Exception e) {
|
||||
log.error("=== Error in notification process ===", e);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -97,7 +114,7 @@ public class MessageService {
|
||||
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
|
||||
User sender = userRepository.findById(senderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
MessageConversation conversation = conversationRepository.findById(conversationId)
|
||||
MessageConversation conversation = conversationRepository.findByIdWithParticipantsAndUsers(conversationId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
|
||||
|
||||
// Join the conversation if not already a participant (useful for channels)
|
||||
@@ -124,22 +141,30 @@ public class MessageService {
|
||||
conversationRepository.save(conversation);
|
||||
|
||||
MessageDto messageDto = toDto(message);
|
||||
String conversationDestination = "/topic/conversation/" + conversation.getId();
|
||||
messagingTemplate.convertAndSend(conversationDestination, messageDto);
|
||||
|
||||
// Notify all participants except sender for updates
|
||||
for (MessageParticipant participant : conversation.getParticipants()) {
|
||||
if (participant.getUser().getId().equals(senderId)) continue;
|
||||
String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages";
|
||||
messagingTemplate.convertAndSend(userDestination, messageDto);
|
||||
// Build participant payloads once to avoid duplicate broadcasts
|
||||
java.util.List<Map<String, Object>> participantInfos = conversation.getParticipants().stream()
|
||||
.filter(p -> !p.getUser().getId().equals(senderId))
|
||||
.map(p -> {
|
||||
Map<String, Object> info = new HashMap<>();
|
||||
info.put("userId", p.getUser().getId());
|
||||
info.put("username", p.getUser().getUsername());
|
||||
info.put("unreadCount", getUnreadMessageCount(p.getUser().getId()));
|
||||
info.put("channelUnread", getUnreadChannelCount(p.getUser().getId()));
|
||||
return info;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
|
||||
String username = participant.getUser().getUsername();
|
||||
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
|
||||
Map<String, Object> conversationInfo = new HashMap<>();
|
||||
conversationInfo.put("id", conversation.getId());
|
||||
conversationInfo.put("participants", participantInfos);
|
||||
|
||||
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
|
||||
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
|
||||
}
|
||||
Map<String, Object> combinedPayload = new HashMap<>();
|
||||
combinedPayload.put("message", messageDto);
|
||||
combinedPayload.put("conversation", conversationInfo);
|
||||
combinedPayload.put("senderId", senderId);
|
||||
|
||||
// Use sender's username for sharding; only one notification is needed
|
||||
notificationProducer.sendNotification(new MessageNotificationPayload(sender.getUsername(), combinedPayload));
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.RabbitMQConfig;
|
||||
import com.openisle.config.ShardInfo;
|
||||
import com.openisle.config.ShardingStrategy;
|
||||
import com.openisle.dto.MessageNotificationPayload;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class NotificationProducer {
|
||||
|
||||
private final RabbitTemplate rabbitTemplate;
|
||||
private final ShardingStrategy shardingStrategy;
|
||||
|
||||
@Value("${rabbitmq.sharding.enabled}")
|
||||
private boolean shardingEnabled;
|
||||
|
||||
public void sendNotification(MessageNotificationPayload payload) {
|
||||
String targetUsername = payload.getTargetUsername();
|
||||
|
||||
try {
|
||||
if (shardingEnabled) {
|
||||
// 使用分片策略发送消息
|
||||
sendShardedNotification(payload, targetUsername);
|
||||
} else {
|
||||
// 使用原始单队列方式发送(向后兼容)
|
||||
sendLegacyNotification(payload);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send message to RabbitMQ for user: {}", targetUsername, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用分片策略发送消息
|
||||
*/
|
||||
private void sendShardedNotification(MessageNotificationPayload payload, String targetUsername) {
|
||||
ShardInfo shardInfo = shardingStrategy.getShardInfo(targetUsername);
|
||||
rabbitTemplate.convertAndSend(
|
||||
RabbitMQConfig.EXCHANGE_NAME,
|
||||
shardInfo.getRoutingKey(),
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用原始单队列方式发送消息(向后兼容)
|
||||
*/
|
||||
private void sendLegacyNotification(MessageNotificationPayload payload) {
|
||||
rabbitTemplate.convertAndSend(
|
||||
RabbitMQConfig.EXCHANGE_NAME,
|
||||
RabbitMQConfig.ROUTING_KEY,
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.EnumSet;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
@@ -40,6 +41,12 @@ public class NotificationService {
|
||||
|
||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
|
||||
NotificationType.COMMENT_REPLY,
|
||||
NotificationType.LOTTERY_WIN,
|
||||
NotificationType.LOTTERY_DRAW
|
||||
);
|
||||
|
||||
private String buildPayload(String body, String url) {
|
||||
// Ensure push notifications contain a link to the related resource so
|
||||
// that verifications can assert its presence and users can navigate
|
||||
@@ -75,7 +82,8 @@ public class NotificationService {
|
||||
n = notificationRepository.save(n);
|
||||
|
||||
// Runnable asyncTask = () -> {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
|
||||
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
|
||||
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
|
||||
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||
sendCustomPush(user, "有人回复了你", url);
|
||||
@@ -187,6 +195,35 @@ public class NotificationService {
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
List<NotificationPreferenceDto> prefs = new ArrayList<>();
|
||||
for (NotificationType nt : EMAIL_TYPES) {
|
||||
NotificationPreferenceDto dto = new NotificationPreferenceDto();
|
||||
dto.setType(nt);
|
||||
dto.setEnabled(!disabled.contains(nt));
|
||||
prefs.add(dto);
|
||||
}
|
||||
return prefs;
|
||||
}
|
||||
|
||||
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
|
||||
if (!EMAIL_TYPES.contains(type)) {
|
||||
return;
|
||||
}
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
|
||||
if (enabled) {
|
||||
disabled.remove(type);
|
||||
} else {
|
||||
disabled.add(type);
|
||||
}
|
||||
userRepository.save(user);
|
||||
}
|
||||
|
||||
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
|
||||
@@ -219,4 +219,32 @@ public class PointService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数
|
||||
* 通过累加所有积分历史记录来重新计算用户的当前积分
|
||||
*/
|
||||
public int recalculateUserPoints(User user) {
|
||||
// 获取用户所有的积分历史记录(由于@Where注解,已删除的记录会被自动过滤)
|
||||
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdDesc(user);
|
||||
|
||||
int totalPoints = 0;
|
||||
for (PointHistory history : histories) {
|
||||
totalPoints += history.getAmount();
|
||||
}
|
||||
|
||||
// 更新用户积分
|
||||
user.setPoint(totalPoints);
|
||||
userRepository.save(user);
|
||||
|
||||
return totalPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新计算用户的积分总数(通过用户名)
|
||||
*/
|
||||
public int recalculateUserPoints(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return recalculateUserPoints(user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -374,14 +374,16 @@ public class PostService {
|
||||
lp.setWinners(winners);
|
||||
lotteryPostRepository.save(lp);
|
||||
for (User w : winners) {
|
||||
if (w.getEmail() != null) {
|
||||
if (w.getEmail() != null &&
|
||||
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
|
||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||
}
|
||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||
}
|
||||
if (lp.getAuthor() != null) {
|
||||
if (lp.getAuthor().getEmail() != null) {
|
||||
if (lp.getAuthor().getEmail() != null &&
|
||||
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
|
||||
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||
}
|
||||
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.config.CachingConfig;
|
||||
import com.openisle.model.Tag;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.TagRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -18,6 +21,7 @@ public class TagService {
|
||||
private final TagValidator tagValidator;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag createTag(String name, String description, String icon, String smallIcon, boolean approved, String creatorUsername) {
|
||||
tagValidator.validate(name);
|
||||
Tag tag = new Tag();
|
||||
@@ -42,6 +46,7 @@ public class TagService {
|
||||
return createTag(name, description, icon, smallIcon, true, null);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public Tag updateTag(Long id, String name, String description, String icon, String smallIcon) {
|
||||
Tag tag = tagRepository.findById(id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
|
||||
@@ -61,6 +66,7 @@ public class TagService {
|
||||
return tagRepository.save(tag);
|
||||
}
|
||||
|
||||
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
|
||||
public void deleteTag(Long id) {
|
||||
tagRepository.deleteById(id);
|
||||
}
|
||||
@@ -85,10 +91,20 @@ public class TagService {
|
||||
return tagRepository.findByApprovedTrue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 该方法每次首页加载都会访问,加入缓存
|
||||
* @param keyword
|
||||
* @return
|
||||
*/
|
||||
@Cacheable(
|
||||
value = CachingConfig.TAG_CACHE_NAME,
|
||||
key = "'searchTags:' + (#keyword ?: '')"//keyword为null的场合返回空
|
||||
)
|
||||
public List<Tag> searchTags(String keyword) {
|
||||
if (keyword == null || keyword.isBlank()) {
|
||||
return tagRepository.findByApprovedTrue();
|
||||
}
|
||||
|
||||
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.dto.TelegramLoginRequest;
|
||||
import com.openisle.model.RegisterMode;
|
||||
import com.openisle.model.Role;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TelegramAuthService {
|
||||
private final UserRepository userRepository;
|
||||
private final AvatarGenerator avatarGenerator;
|
||||
|
||||
@Value("${telegram.bot-token:}")
|
||||
private String botToken;
|
||||
|
||||
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
|
||||
try {
|
||||
if (botToken == null || botToken.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String dataCheckString = buildDataCheckString(req);
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
|
||||
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
|
||||
String hex = bytesToHex(hash);
|
||||
if (!hex.equalsIgnoreCase(req.getHash())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String username = req.getUsername();
|
||||
String email = (username != null ? username : req.getId()) + "@telegram.org";
|
||||
String avatar = req.getPhotoUrl();
|
||||
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildDataCheckString(TelegramLoginRequest req) {
|
||||
List<String> data = new ArrayList<>();
|
||||
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
|
||||
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
|
||||
if (req.getId() != null) data.add("id=" + req.getId());
|
||||
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
|
||||
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
|
||||
if (req.getUsername() != null) data.add("username=" + req.getUsername());
|
||||
Collections.sort(data);
|
||||
return String.join("\n", data);
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
|
||||
Optional<User> existing = userRepository.findByEmail(email);
|
||||
if (existing.isPresent()) {
|
||||
User user = existing.get();
|
||||
if (!user.isVerified()) {
|
||||
user.setVerified(true);
|
||||
user.setVerificationCode(null);
|
||||
userRepository.save(user);
|
||||
}
|
||||
return new AuthResult(user, false);
|
||||
}
|
||||
String baseUsername = username != null ? username : email.split("@")[0];
|
||||
String finalUsername = baseUsername;
|
||||
int suffix = 1;
|
||||
while (userRepository.findByUsername(finalUsername).isPresent()) {
|
||||
finalUsername = baseUsername + suffix++;
|
||||
}
|
||||
User user = new User();
|
||||
user.setUsername(finalUsername);
|
||||
user.setEmail(email);
|
||||
user.setPassword("");
|
||||
user.setRole(Role.USER);
|
||||
user.setVerified(true);
|
||||
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
|
||||
if (avatar != null) {
|
||||
user.setAvatar(avatar);
|
||||
} else {
|
||||
user.setAvatar(avatarGenerator.generate(finalUsername));
|
||||
}
|
||||
return new AuthResult(userRepository.save(user), true);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
# for spring boot
|
||||
server.port=${SERVER_PORT:8080}
|
||||
|
||||
# for mysql
|
||||
logging.level.root=${LOG_LEVEL:INFO}
|
||||
logging.level.com.openisle.service.CosImageUploader=DEBUG
|
||||
@@ -6,6 +9,11 @@ spring.datasource.username=${MYSQL_USER:root}
|
||||
spring.datasource.password=${MYSQL_PASSWORD:password}
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# for redis
|
||||
spring.data.redis.host=${REDIS_HOST:localhost}
|
||||
spring.data.redis.port=${REDIS_PORT:6379}
|
||||
spring.data.redis.database=0
|
||||
|
||||
# for jwt
|
||||
app.jwt.secret=${JWT_SECRET:jwt_sec}
|
||||
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
|
||||
@@ -69,6 +77,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
|
||||
# Twitter OAuth configuration
|
||||
twitter.client-id=${TWITTER_CLIENT_ID:}
|
||||
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
||||
# Telegram login configuration
|
||||
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
|
||||
# OpenAI configuration
|
||||
openai.api-key=${OPENAI_API_KEY:}
|
||||
openai.model=${OPENAI_MODEL:gpt-4o}
|
||||
@@ -81,3 +91,23 @@ app.website-url=${WEBSITE_URL:https://www.open-isle.com}
|
||||
# Web push configuration
|
||||
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
|
||||
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}
|
||||
|
||||
# RabbitMQ Configuration
|
||||
spring.rabbitmq.host=${RABBITMQ_HOST:localhost}
|
||||
spring.rabbitmq.port=${RABBITMQ_PORT:5672}
|
||||
spring.rabbitmq.username=${RABBITMQ_USERNAME:guest}
|
||||
spring.rabbitmq.password=${RABBITMQ_PASSWORD:guest}
|
||||
|
||||
# RabbitMQ 队列配置 - 修改为非持久化以匹配现有队列
|
||||
rabbitmq.queue.durable=true
|
||||
rabbitmq.sharding.enabled=true
|
||||
|
||||
# springdoc-openapi-starter-webmvc-api
|
||||
# see https://springdoc.org/#springdoc-openapi-core-properties
|
||||
springdoc.api-docs.path=/api/v3/api-docs
|
||||
springdoc.api-docs.enabled=true
|
||||
springdoc.info.title=OpenIsle
|
||||
springdoc.info.description=OpenIsle Open API Documentation
|
||||
springdoc.info.version=0.0.1
|
||||
springdoc.info.scheme=Bearer
|
||||
springdoc.info.header=Authorization
|
||||
|
||||
81
backend/src/main/resources/db/init/init_script.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- 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$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', 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$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', 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$m.lLbT3wFtnzFMi7JqN17ecv/dzH704WzU1f/xvQ0nVz4XxTXPT0K', 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);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add logical delete support for comments and point_histories tables
|
||||
|
||||
-- Add deleted_at column to comments table
|
||||
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
|
||||
|
||||
-- Add deleted_at column to point_histories table
|
||||
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
|
||||
|
||||
-- Add index for better performance on logical delete queries
|
||||
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
|
||||
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);
|
||||
@@ -6,6 +6,8 @@ import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.CommentSubscriptionRepository;
|
||||
import com.openisle.repository.NotificationRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.service.PointService;
|
||||
import com.openisle.exception.RateLimitException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -24,10 +26,12 @@ class CommentServiceTest {
|
||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
|
||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
|
||||
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader);
|
||||
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader);
|
||||
|
||||
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -37,7 +39,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -69,6 +71,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -84,7 +88,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -122,6 +126,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -137,7 +143,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
@@ -156,6 +162,8 @@ class PostServiceTest {
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
@@ -171,7 +179,7 @@ class PostServiceTest {
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
11
docker/.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# 前端访问端口
|
||||
SERVER_PORT=8080
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_ROOT_PASSWORD=toor
|
||||
|
||||
# 会覆盖 `open-isle.env`
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=openisle
|
||||
MYSQL_USER=<数据库用户名>
|
||||
MYSQL_PASSWORD=<数据库密码>
|
||||
45
docker/docker-compose.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
# MySQL service
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: openisle-mysql
|
||||
restart: always
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
ports:
|
||||
- "${MYSQL_PORT}:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
|
||||
networks:
|
||||
- openisle-network
|
||||
|
||||
# Java spring boot service
|
||||
springboot:
|
||||
image: maven:3.9-eclipse-temurin-17
|
||||
container_name: openisle-springboot
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ../backend/open-isle.env
|
||||
- ./.env
|
||||
environment:
|
||||
- MYSQL_URL=jdbc:mysql://mysql:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
|
||||
ports:
|
||||
- "${SERVER_PORT}:8080"
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
- maven-repo:/root/.m2
|
||||
depends_on:
|
||||
- mysql
|
||||
command: mvn clean spring-boot:run -Dmaven.test.skip=true
|
||||
networks:
|
||||
- openisle-network
|
||||
|
||||
networks:
|
||||
openisle-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
maven-repo:
|
||||
10
frontend_nuxt/.env.dev.example
Normal file
@@ -0,0 +1,10 @@
|
||||
; 本地部署后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
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_DISCORD_CLIENT_ID=1394985417044000779
|
||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||
@@ -3,13 +3,17 @@
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.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://www.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
|
||||
|
||||
13
frontend_nuxt/.env.production.example
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
|
||||
; 正式环境/生产环境
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
|
||||
; 生产环境ws后端
|
||||
NUXT_PUBLIC_WEBSOCKET_URL=https://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
|
||||
@@ -1,16 +1,17 @@
|
||||
; 本地部署后端
|
||||
; 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://www.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_WEBSITE_BASE_URL=https://www.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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
class="app-new-post-icon"
|
||||
@click="goToNewPost"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
<edit />
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
@@ -58,6 +58,7 @@ const hideMenu = computed(() => {
|
||||
'/discord-callback',
|
||||
'/forgot-password',
|
||||
'/google-callback',
|
||||
'/telegram-callback',
|
||||
].includes(useRoute().path)
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
--article-info-background-color: #f0f0f0;
|
||||
--activity-card-background-color: #fafafa;
|
||||
--poll-option-button-background-color: rgb(218, 218, 218);
|
||||
--telegram-bg: #caedff74;
|
||||
--telegram-bg-hover: #67a2c088;
|
||||
--twitter-bg: rgb(68, 68, 68);
|
||||
--twitter-bg-hover: rgb(91, 91, 91);
|
||||
--discord-bg: #5865f258;
|
||||
--discord-bg-hover: #5865f2b1;
|
||||
--featured-color: rgb(255, 170, 0);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
@@ -155,6 +162,9 @@ body {
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #d0d7de;
|
||||
color: var(--blockquote-text-color);
|
||||
background-color: var(--menu-selected-background-color);
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
@@ -239,6 +249,14 @@ body {
|
||||
overflow-x: auto; /* 小屏可横向滚动 */
|
||||
}
|
||||
|
||||
.info-content-text hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--normal-border-color);
|
||||
padding: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-content-text thead th {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 512"><path fill="#5865F2" d="M105 0h302c57.928.155 104.845 47.072 105 104.996V407c-.155 57.926-47.072 104.844-104.996 104.998L105 512C47.074 511.844.156 464.926.002 407.003L0 105C.156 47.072 47.074.155 104.997 0H105z"/><g data-name="å¾å± 2"><g data-name="Discord Logos"><path fill="#fff" fill-rule="nonzero" d="M368.896 153.381a269.506 269.506 0 00-67.118-20.637 186.88 186.88 0 00-8.57 17.475 250.337 250.337 0 00-37.247-2.8c-12.447 0-24.955.946-37.25 2.776-2.511-5.927-5.427-11.804-8.592-17.454a271.73 271.73 0 00-67.133 20.681c-42.479 62.841-53.991 124.112-48.235 184.513a270.622 270.622 0 0082.308 41.312c6.637-8.959 12.582-18.497 17.63-28.423a173.808 173.808 0 01-27.772-13.253c2.328-1.688 4.605-3.427 6.805-5.117 25.726 12.083 53.836 18.385 82.277 18.385 28.442 0 56.551-6.302 82.279-18.387 2.226 1.817 4.503 3.557 6.805 5.117a175.002 175.002 0 01-27.823 13.289 197.847 197.847 0 0017.631 28.4 269.513 269.513 0 0082.363-41.305l-.007.007c6.754-70.045-11.538-130.753-48.351-184.579zM201.968 300.789c-16.04 0-29.292-14.557-29.292-32.465s12.791-32.592 29.241-32.592 29.599 14.684 29.318 32.592c-.282 17.908-12.919 32.465-29.267 32.465zm108.062 0c-16.066 0-29.267-14.557-29.267-32.465s12.791-32.592 29.267-32.592c16.475 0 29.522 14.684 29.241 32.592-.281 17.908-12.894 32.465-29.241 32.465z" data-name="Discord Logo - Large - White"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
1
frontend_nuxt/assets/icons/telegram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><defs><clipPath id="A"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118"/></clipPath><linearGradient x1="133.903" y1="13.999" x2="133.903" y2="249.999" gradientUnits="userSpaceOnUse" spreadMethod="pad" id="B"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient><clipPath id="C"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="D"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="E"><path d="M0 265.9h266.987V0H0z"/></clipPath></defs><g transform="matrix(.271187 0 0 -.271187 -4.312678 67.796339)"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118" fill="url(#B)" clip-path="url(#A)"/><g clip-path="url(#C)"><path d="M95.778 123.374l14-38.75S111.528 81 113.403 81s29.75 29 29.75 29l31 59.875-77.875-36.5z" fill="#c8daea"/></g><g clip-path="url(#D)"><path d="M114.34 113.436l-2.688-28.562s-1.125-8.75 7.625 0 17.125 15.5 17.125 15.5" fill="#a9c6d8"/></g><g clip-path="url(#E)"><path d="M96.03 121.99l-28.795 9.383s-3.437 1.395-2.333 4.562c.228.653.687 1.208 2.062 2.167 6.382 4.447 118.104 44.604 118.104 44.604s3.155 1.062 5.02.356c.852-.323 1.396-.688 1.854-2.02.167-.485.263-1.516.25-2.542-.01-.74-.1-1.425-.166-2.5-.68-10.98-21.04-92.918-21.04-92.918s-1.218-4.795-5.583-4.958c-1.592-.06-3.524.263-5.834 2.25-8.565 7.368-38.172 27.265-44.713 31.64-.37.246-.474.567-.537.88-.092.46.4 1.034.4 1.034s51.552 45.825 52.924 50.633c.106.373-.293.557-.834.396-3.424-1.26-62.78-38.74-69.33-42.88-.383-.242-1.457-.086-1.457-.086" fill="#fff"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -1,4 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Twitter icon</title>
|
||||
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-480 -466.815 2160 2160"><circle fill="#444" cx="600" cy="613.185" r="1080"/><path fill="#fff" d="M306.615 79.694H144.011L892.476 1150.3h162.604ZM0 0h357.328l309.814 450.883L1055.03 0h105.86L714.15 519.295 1200 1226.37H842.672L515.493 750.215 105.866 1226.37H0l468.485-544.568Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 764 B After Width: | Height: | Size: 335 B |
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<i v-if="icon" :class="['base-input-icon', icon]" />
|
||||
<component v-if="icon" :is="icon" class="base-input-icon" size="14" />
|
||||
|
||||
<!-- 普通输入框 -->
|
||||
<input
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
icon: { type: [String, Object], default: '' },
|
||||
type: { type: String, default: 'text' },
|
||||
textarea: { type: Boolean, default: false },
|
||||
},
|
||||
@@ -66,7 +66,6 @@ export default {
|
||||
|
||||
.base-input-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.base-input-text {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="base-placeholder">
|
||||
<i :class="['base-placeholder-icon', icon]" />
|
||||
<component :is="icon" class="base-placeholder-icon" theme="outline" />
|
||||
<div class="base-placeholder-text">
|
||||
<slot>{{ text }}</slot>
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
name: 'BasePlaceholder',
|
||||
props: {
|
||||
text: { type: String, default: '' },
|
||||
icon: { type: String, default: 'fas fa-inbox' },
|
||||
icon: { type: String, default: 'inbox' },
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@ export default {
|
||||
height: 300px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.base-placeholder-icon,
|
||||
.base-placeholder-text {
|
||||
font-size: 16px;
|
||||
color: var(--text-color);
|
||||
|
||||
@@ -8,7 +8,11 @@
|
||||
:class="['base-tabs-item', { selected: modelValue === tab.key }]"
|
||||
@click="$emit('update:modelValue', tab.key)"
|
||||
>
|
||||
<i v-if="tab.icon" :class="tab.icon"></i>
|
||||
<component
|
||||
v-if="tab.icon && (typeof tab.icon !== 'string' || !tab.icon.includes(' '))"
|
||||
:is="tab.icon"
|
||||
class="base-tabs-item-icon"
|
||||
/>
|
||||
<div class="base-tabs-item-label">{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,6 +76,7 @@ function onTouchEnd(e) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.base-tabs-item-icon,
|
||||
.base-tabs-item i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
@click="item.iconClick && item.iconClick()"
|
||||
>
|
||||
<BaseImage v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
|
||||
<i v-else-if="item.icon" :class="item.icon"></i>
|
||||
<component
|
||||
v-else-if="item.icon && (typeof item.icon !== 'string' || !item.icon.includes(' '))"
|
||||
:is="item.icon"
|
||||
:size="20"
|
||||
/>
|
||||
<BaseImage v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
<!-- <i v-else :class="['option-icon', option.icon]"></i> -->
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="comment-bottom-container">
|
||||
<div class="comment-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发布评论 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发布中... </template>
|
||||
<template v-else> <loading-four /> 发布中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,19 +15,23 @@
|
||||
<div class="common-info-content-header">
|
||||
<div class="info-content-header-left">
|
||||
<span class="user-name">{{ comment.userName }}</span>
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon" />
|
||||
<NuxtLink
|
||||
v-if="comment.medal"
|
||||
class="medal-name"
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</NuxtLink
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<pin v-if="comment.pinned" class="pin-icon" />
|
||||
<span v-if="level >= 2" class="reply-item">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<next class="reply-icon" />
|
||||
<span class="reply-info">
|
||||
<BaseImage class="reply-avatar" :src="comment.parentUserAvatar || '/default-avatar.svg'" alt="avatar"
|
||||
@click="comment.parentUserClick && comment.parentUserClick()" />
|
||||
<BaseImage
|
||||
class="reply-avatar"
|
||||
:src="comment.parentUserAvatar || '/default-avatar.svg'"
|
||||
alt="avatar"
|
||||
@click="comment.parentUserClick && comment.parentUserClick()"
|
||||
/>
|
||||
<span class="reply-user-name">{{ comment.parentUserName }}</span>
|
||||
</span>
|
||||
</span>
|
||||
@@ -36,7 +40,7 @@
|
||||
<div class="info-content-header-right">
|
||||
<DropdownMenu v-if="commentMenuItems.length > 0" :items="commentMenuItems">
|
||||
<template #trigger>
|
||||
<i class="fas fa-ellipsis-vertical action-menu-icon"></i>
|
||||
<more-one class="action-menu-icon" />
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -49,10 +53,10 @@
|
||||
<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">
|
||||
<i class="far fa-comment"></i>
|
||||
<comment-icon />
|
||||
</div>
|
||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||
<i class="fas fa-link"></i>
|
||||
<link-icon />
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
@@ -67,8 +71,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="replyCount && level < 2" class="reply-toggle" @click="toggleReplies">
|
||||
<i v-if="showReplies" class="fas fa-chevron-up reply-toggle-icon"></i>
|
||||
<i v-else class="fas fa-chevron-down reply-toggle-icon"></i>
|
||||
<up v-if="showReplies" class="reply-toggle-icon" />
|
||||
<down v-else class="reply-toggle-icon" />
|
||||
{{ replyCount }}条回复
|
||||
</div>
|
||||
<div v-if="showReplies && level < 2" class="reply-list">
|
||||
@@ -371,7 +375,6 @@ const handleContentClick = (e) => {
|
||||
}
|
||||
|
||||
.reply-toggle-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.common-info-content-header {
|
||||
@@ -381,7 +384,8 @@ const handleContentClick = (e) => {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reply-item, .reply-info {
|
||||
.reply-item,
|
||||
.reply-info {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
@@ -397,13 +401,16 @@ const handleContentClick = (e) => {
|
||||
|
||||
.reply-icon {
|
||||
color: var(--primary-color);
|
||||
margin-right: 10px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
opacity: 0.5;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.reply-user-name {
|
||||
opacity: 0.3;
|
||||
display: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.medal-name {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class="option-icon"
|
||||
:alt="label.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', label.icon]"></i>
|
||||
<component v-else :is="label.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ label.name }}</span>
|
||||
</div>
|
||||
@@ -38,14 +38,14 @@
|
||||
class="option-icon"
|
||||
:alt="selectedLabels[0].name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
|
||||
<component v-else :is="selectedLabels[0].icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ selectedLabels[0].name }}</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else class="placeholder">{{ placeholder }}</span>
|
||||
</template>
|
||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||
<down class="dropdown-caret" />
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
@@ -54,7 +54,7 @@
|
||||
v-click-outside="close"
|
||||
>
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<search-icon class="search-icon" />
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
@@ -75,7 +75,7 @@
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
@@ -85,12 +85,12 @@
|
||||
<Teleport to="body">
|
||||
<div v-if="open && isMobile" class="dropdown-mobile-page">
|
||||
<div class="dropdown-mobile-header">
|
||||
<i class="fas fa-arrow-left" @click="close"></i>
|
||||
<next class="back-icon" @click="close" />
|
||||
<span class="mobile-title">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="dropdown-mobile-menu">
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<search-icon class="search-icon" />
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
@@ -111,7 +111,7 @@
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
@@ -303,6 +303,10 @@ export default {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-caret {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.selected-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -326,6 +330,10 @@ export default {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -376,6 +384,11 @@ export default {
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.back-icon {
|
||||
transform: rotate(180deg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-mobile-menu {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="header-content-left">
|
||||
<div v-if="showMenuBtn" class="menu-btn-wrapper">
|
||||
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
|
||||
<i class="fas fa-bars micon"></i>
|
||||
<application-menu class="micon"></application-menu>
|
||||
</button>
|
||||
<span
|
||||
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
|
||||
@@ -25,34 +25,34 @@
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<i class="fas fa-search"></i>
|
||||
<search-icon />
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<component :is="iconClass" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
||||
<i class="fas fa-copy"></i>
|
||||
<copy />
|
||||
邀请
|
||||
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
|
||||
<loading v-if="isCopying" />
|
||||
</div>
|
||||
|
||||
<ToolTip content="复制RSS链接" placement="bottom">
|
||||
<div class="rss-icon" @click="copyRssLink">
|
||||
<i class="fas fa-rss"></i>
|
||||
<rss />
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<i class="fas fa-edit"></i>
|
||||
<edit />
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<i class="fas fa-comments"></i>
|
||||
<message-emoji />
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||
unreadMessageCount
|
||||
}}</span>
|
||||
@@ -64,7 +64,7 @@
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||
<down />
|
||||
</div>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
@@ -226,11 +226,11 @@ const headerMenuItems = computed(() => [
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
return 'Moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
return 'SunOne'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
return 'ComputerOne'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="invite-code-activity">
|
||||
<div class="invite-code-description">
|
||||
<div class="invite-code-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
<span class="invite-code-description-title-text">邀请规则说明</span>
|
||||
</div>
|
||||
<div class="invite-code-description-content">
|
||||
@@ -17,7 +17,7 @@
|
||||
<div v-if="inviteLink" class="invite-code-link-content">
|
||||
<p class="invite-code-link-content-text">
|
||||
邀请链接:{{ inviteLink }}
|
||||
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
|
||||
<span @click="copyLink"><copy class="copy-icon" /></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="login-overlay">
|
||||
<div class="login-overlay-blur"></div>
|
||||
<div class="login-overlay-content">
|
||||
<i class="fa-solid fa-user login-overlay-icon"></i>
|
||||
<user-icon class="login-overlay-icon" />
|
||||
<div class="login-overlay-text">请先登录,点击跳转到登录页面</div>
|
||||
<div class="login-overlay-button" @click="goLogin">登录</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="prize-row-title">奖品图片</span>
|
||||
<label class="prize-container">
|
||||
<BaseImage v-if="data.prizeIcon" :src="data.prizeIcon" class="prize-preview" alt="prize" />
|
||||
<i v-else class="fa-solid fa-image default-prize-icon"></i>
|
||||
<image-files v-else class="default-prize-icon" />
|
||||
<div class="prize-overlay">上传奖品图片</div>
|
||||
<input type="file" class="prize-input" accept="image/*" @change="onPrizeIconChange" />
|
||||
</label>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="menu-content">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<hashtag-key class="menu-item-icon" />
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -13,7 +13,7 @@
|
||||
to="/new-post"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-edit"></i>
|
||||
<edit class="menu-item-icon" />
|
||||
<span class="menu-item-text">发帖</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -22,7 +22,7 @@
|
||||
to="/message"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-envelope"></i>
|
||||
<remind class="menu-item-icon" />
|
||||
<span class="menu-item-text">我的消息</span>
|
||||
<span v-if="unreadCount > 0" class="unread-container">
|
||||
<span class="unread"> {{ showUnreadCount }} </span>
|
||||
@@ -34,7 +34,7 @@
|
||||
to="/about"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-info-circle"></i>
|
||||
<info-icon class="menu-item-icon" />
|
||||
<span class="menu-item-text">关于</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -43,7 +43,7 @@
|
||||
to="/activities"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-gift"></i>
|
||||
<gift class="menu-item-icon" />
|
||||
<span class="menu-item-text">🔥 活动</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -53,7 +53,7 @@
|
||||
to="/about/stats"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||
<chart-line class="menu-item-icon" />
|
||||
<span class="menu-item-text">站点统计</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
@@ -63,7 +63,7 @@
|
||||
to="/points"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
<i class="menu-item-icon fas fa-coins"></i>
|
||||
<finance class="menu-item-icon" />
|
||||
<span class="menu-item-text">
|
||||
积分商城
|
||||
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||
@@ -74,7 +74,8 @@
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="categoryOpen = !categoryOpen">
|
||||
<span>类别</span>
|
||||
<i :class="categoryOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
<up v-if="categoryOpen" class="menu-item-icon" />
|
||||
<down v-else class="menu-item-icon" />
|
||||
</div>
|
||||
<div v-if="categoryOpen" class="section-items">
|
||||
<div v-if="isLoadingCategory" class="menu-loading-container">
|
||||
@@ -94,7 +95,7 @@
|
||||
class="section-item-icon"
|
||||
:alt="c.name"
|
||||
/>
|
||||
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
|
||||
<component v-else :is="c.smallIcon || c.icon" class="section-item-icon" />
|
||||
</template>
|
||||
<span class="section-item-text">
|
||||
{{ c.name }}
|
||||
@@ -107,7 +108,8 @@
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>标签</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
<up v-if="tagOpen" class="menu-item-icon" />
|
||||
<down v-else class="menu-item-icon" />
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
<div v-if="isLoadingTag" class="menu-loading-container">
|
||||
@@ -120,7 +122,7 @@
|
||||
class="section-item-icon"
|
||||
:alt="t.name"
|
||||
/>
|
||||
<i v-else class="section-item-icon fas fa-hashtag"></i>
|
||||
<tag-one v-else class="section-item-icon" />
|
||||
<span class="section-item-text"
|
||||
>{{ t.name }} <span class="section-item-text-count">x {{ t.count }}</span></span
|
||||
>
|
||||
@@ -133,7 +135,7 @@
|
||||
<ClientOnly v-if="!isMobile">
|
||||
<div class="menu-footer">
|
||||
<div class="menu-footer-btn" @click="cycleTheme">
|
||||
<i :class="iconClass"></i>
|
||||
<component :is="iconClass" class="menu-item-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
@@ -193,11 +195,11 @@ const {
|
||||
const iconClass = computed(() => {
|
||||
switch (themeState.mode) {
|
||||
case ThemeMode.DARK:
|
||||
return 'fas fa-moon'
|
||||
return 'Moon'
|
||||
case ThemeMode.LIGHT:
|
||||
return 'fas fa-sun'
|
||||
return 'SunOne'
|
||||
default:
|
||||
return 'fas fa-desktop'
|
||||
return 'ComputerOne'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="message-bottom-container">
|
||||
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发送 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
|
||||
<template v-else> <loading-four /> 发送中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,20 +3,10 @@
|
||||
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
|
||||
|
||||
<div class="float-actions">
|
||||
<i
|
||||
class="fas fa-chevron-down"
|
||||
v-if="floatHeight !== MINI_HEIGHT"
|
||||
title="收起至 100px"
|
||||
@click="collapseToMini"
|
||||
></i>
|
||||
<i
|
||||
class="fas fa-chevron-up"
|
||||
v-if="floatHeight !== DEFAULT_HEIGHT"
|
||||
title="回弹至 60vh"
|
||||
@click="reboundToDefault"
|
||||
></i>
|
||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||
<i class="fas fa-times" title="关闭" @click="close"></i>
|
||||
<down v-if="floatHeight !== MINI_HEIGHT" title="收起至 100px" @click="collapseToMini" />
|
||||
<up v-if="floatHeight !== DEFAULT_HEIGHT" title="回弹至 60vh" @click="reboundToDefault" />
|
||||
<expand-up title="在页面中打开" @click="expand" />
|
||||
<close-icon title="关闭" @click="close" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="milk-tea-activity">
|
||||
<div class="milk-tea-description">
|
||||
<div class="milk-tea-description-title">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
<span class="milk-tea-description-title-text">升级规则说明</span>
|
||||
</div>
|
||||
<div class="milk-tea-description-content">
|
||||
@@ -29,7 +29,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="user-level">
|
||||
<div class="user-level-text"><i class="fas fa-user-circle"></i> 请登录查看自身等级</div>
|
||||
<div class="user-level-text"><user-icon /> 请登录查看自身等级</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -10,13 +10,14 @@
|
||||
:src="lottery.prizeIcon"
|
||||
alt="prize"
|
||||
/>
|
||||
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
|
||||
<gift v-else class="default-prize-icon" />
|
||||
</div>
|
||||
<div class="prize-name">{{ lottery.prizeDescription }}</div>
|
||||
<div class="prize-count">x {{ lottery.prizeCount }}</div>
|
||||
</div>
|
||||
<div class="prize-end-time prize-info-right">
|
||||
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
|
||||
<stopwatch v-if="!lotteryEnded" class="prize-end-time-icon" />
|
||||
<div v-if="!isMobile && !lotteryEnded" class="prize-end-time-title">离结束</div>
|
||||
<div class="prize-end-time-value">{{ countdown }}</div>
|
||||
<div v-if="!isMobile" class="join-prize-button-container-desktop">
|
||||
<div
|
||||
@@ -25,7 +26,8 @@
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
参与抽奖 <paper-money-two class="join-prize-button-text-icon" />
|
||||
{{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
@@ -42,7 +44,7 @@
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
参与抽奖 <paper-money-two class="join-prize-button-text-icon" /> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
@@ -60,7 +62,7 @@
|
||||
@click="gotoUser(p.id)"
|
||||
/>
|
||||
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
|
||||
<i class="fas fa-medal medal-icon"></i>
|
||||
<medal-one class="medal-icon"></medal-one>
|
||||
<span class="prize-member-winner-name">获奖者: </span>
|
||||
<BaseImage
|
||||
v-for="w in lotteryWinners"
|
||||
@@ -84,6 +86,7 @@ import { getToken, authState } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { useCountdown } from '~/composables/useCountdown'
|
||||
|
||||
const props = defineProps({
|
||||
lottery: { type: Object, required: true },
|
||||
@@ -95,54 +98,14 @@ const isMobile = useIsMobile()
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const lotteryParticipants = computed(() => props.lottery?.participants || [])
|
||||
const lotteryWinners = computed(() => props.lottery?.winners || [])
|
||||
const lotteryEnded = computed(() => {
|
||||
if (!props.lottery || !props.lottery.endTime) return false
|
||||
return new Date(props.lottery.endTime).getTime() <= Date.now()
|
||||
})
|
||||
// 倒计时和结束flg
|
||||
const { countdown, isEnded } = useCountdown(props.lottery?.endTime)
|
||||
const lotteryEnded = computed(() => isEnded.value)
|
||||
const hasJoined = computed(() => {
|
||||
if (!loggedIn.value) return false
|
||||
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||
})
|
||||
|
||||
const countdown = ref('00:00:00')
|
||||
let timer = null
|
||||
const updateCountdown = () => {
|
||||
if (!props.lottery || !props.lottery.endTime) {
|
||||
countdown.value = '00:00:00'
|
||||
return
|
||||
}
|
||||
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '00:00:00'
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
|
||||
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
|
||||
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
updateCountdown()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
watch(
|
||||
() => props.lottery?.endTime,
|
||||
() => {
|
||||
if (props.lottery && props.lottery.endTime) startCountdown()
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (props.lottery && props.lottery.endTime) startCountdown()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -229,6 +192,11 @@ const joinLottery = async () => {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.prize-end-time-icon {
|
||||
font-size: 13px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.prize-end-time-title {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="poll-title-section">
|
||||
<div class="poll-option-title" v-if="poll.multiple">多选</div>
|
||||
<div class="poll-option-title" v-else>单选</div>
|
||||
|
||||
<div class="poll-left-time">
|
||||
<stopwatch class="poll-left-time-icon" />
|
||||
<div class="poll-left-time-title">离结束</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="poll.multiple">
|
||||
<div
|
||||
v-for="(opt, idx) in poll.options"
|
||||
@@ -45,13 +55,7 @@
|
||||
</div>
|
||||
|
||||
<div class="multi-selection-container">
|
||||
<div class="multi-selection-title">
|
||||
<i class="fas fa-info-circle info-icon"></i>
|
||||
该投票为多选
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitMultiPoll">
|
||||
<i class="fas fa-plus"></i> 加入投票
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitMultiPoll"><check /> 确认投票</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -71,13 +75,7 @@
|
||||
</div>
|
||||
|
||||
<div class="single-selection-container">
|
||||
<div class="single-selection-title">
|
||||
<i class="fas fa-info-circle info-icon"></i>
|
||||
该投票为单选
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitSinglePoll">
|
||||
<i class="fas fa-plus"></i> 加入投票
|
||||
</div>
|
||||
<div class="join-poll-button" @click="submitSinglePoll"><check /> 确认投票</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -93,19 +91,23 @@
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = false"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> 投票
|
||||
<arrow-left /> 投票
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!pollEnded && !hasVoted"
|
||||
class="poll-option-button"
|
||||
@click="showPollResult = true"
|
||||
>
|
||||
<i class="fas fa-chart-bar"></i> 结果
|
||||
<chart-histogram /> 结果
|
||||
</div>
|
||||
|
||||
<div class="poll-left-time">
|
||||
<div class="poll-left-time-title">离结束还有</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
<div v-else-if="pollEnded" class="poll-option-hint"><stopwatch /> 投票已结束</div>
|
||||
<div v-else class="poll-option-hint">
|
||||
<div>您已投票,等待结束查看结果</div>
|
||||
<div class="poll-left-time">
|
||||
<stopwatch class="poll-left-time-icon" />
|
||||
<div class="poll-left-time-title">离结束</div>
|
||||
<div class="poll-left-time-value">{{ countdown }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,6 +118,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useRuntimeConfig } from '#imports'
|
||||
import { useCountdown } from '~/composables/useCountdown'
|
||||
|
||||
const props = defineProps({
|
||||
poll: { type: Object, required: true },
|
||||
@@ -138,10 +141,9 @@ const pollPercentages = computed(() =>
|
||||
})
|
||||
: [],
|
||||
)
|
||||
const pollEnded = computed(() => {
|
||||
if (!props.poll || !props.poll.endTime) return false
|
||||
return new Date(props.poll.endTime).getTime() <= Date.now()
|
||||
})
|
||||
// 倒计时
|
||||
const { countdown, isEnded } = useCountdown(props.poll?.endTime)
|
||||
const pollEnded = computed(() => isEnded.value)
|
||||
const hasVoted = computed(() => {
|
||||
if (!loggedIn.value) return false
|
||||
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
|
||||
@@ -150,45 +152,6 @@ watch([hasVoted, pollEnded], ([voted, ended]) => {
|
||||
if (voted || ended) showPollResult.value = true
|
||||
})
|
||||
|
||||
const countdown = ref('00:00:00')
|
||||
let timer = null
|
||||
const updateCountdown = () => {
|
||||
if (!props.poll || !props.poll.endTime) {
|
||||
countdown.value = '00:00:00'
|
||||
return
|
||||
}
|
||||
const diff = new Date(props.poll.endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '00:00:00'
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
|
||||
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
|
||||
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
|
||||
countdown.value = `${h}:${m}:${s}`
|
||||
}
|
||||
const startCountdown = () => {
|
||||
updateCountdown()
|
||||
if (timer) clearInterval(timer)
|
||||
timer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
watch(
|
||||
() => props.poll?.endTime,
|
||||
() => {
|
||||
if (props.poll && props.poll.endTime) startCountdown()
|
||||
},
|
||||
)
|
||||
onMounted(() => {
|
||||
if (props.poll && props.poll.endTime) startCountdown()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
@@ -359,6 +322,18 @@ const submitMultiPoll = async () => {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.poll-left-time-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.poll-option-hint {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.poll-left-time-title {
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
@@ -397,7 +372,8 @@ const submitMultiPoll = async () => {
|
||||
|
||||
.multi-selection-container,
|
||||
.single-selection-container {
|
||||
padding: 20px 15px 20px 5px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
@@ -409,6 +385,22 @@ const submitMultiPoll = async () => {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.poll-title-section {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
flex-direction: row;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.poll-option-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.poll-left-time {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export default {
|
||||
|
||||
const fetchTypes = async () => {
|
||||
return [
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' },
|
||||
{ id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' },
|
||||
{ id: 'NORMAL', name: '普通帖子', icon: 'file-text' },
|
||||
{ id: 'LOTTERY', name: '抽奖帖子', icon: 'gift' },
|
||||
{ id: 'POLL', name: '投票帖子', icon: 'ranking-list' },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,12 @@
|
||||
:class="{ selected: userReacted(r.type) }"
|
||||
@click="toggleReaction(r.type)"
|
||||
>
|
||||
<BaseImage :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
|
||||
<BaseImage :src="reactionEmojiMap[r.type]" class="reaction-emoji" alt="emoji" />
|
||||
<div>{{ counts[r.type] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
|
||||
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="displayedReactions.length">
|
||||
@@ -42,8 +41,8 @@
|
||||
class="make-reaction-item like-reaction"
|
||||
@click="toggleReaction('LIKE')"
|
||||
>
|
||||
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
|
||||
<i v-else class="fas fa-heart"></i>
|
||||
<like v-if="!userReacted('LIKE')" />
|
||||
<like v-else theme="filled" />
|
||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
@@ -220,6 +219,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reactions-viewer {
|
||||
@@ -229,6 +229,12 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.reactions-viewer-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -337,5 +343,23 @@ onMounted(async () => {
|
||||
font-size: 16px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
.reactions-viewer-item.placeholder,
|
||||
.reactions-viewer-single-item {
|
||||
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;
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<search-icon class="search-input-icon" />
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="search-option-item">
|
||||
<i :class="['result-icon', iconMap[option.type] || 'fas fa-question']"></i>
|
||||
<component :is="iconMap[option.type]" class="result-icon" />
|
||||
<div class="result-body">
|
||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
||||
@@ -83,11 +83,12 @@ const highlight = (text) => {
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
user: 'fas fa-user',
|
||||
post: 'fas fa-file-alt',
|
||||
comment: 'fas fa-comment',
|
||||
category: 'fas fa-folder',
|
||||
tag: 'fas fa-hashtag',
|
||||
user: 'UserIcon',
|
||||
post: 'FileText',
|
||||
post_title: 'FileText',
|
||||
comment: 'CommentIcon',
|
||||
category: 'Inbox',
|
||||
tag: 'TagOne',
|
||||
}
|
||||
|
||||
watch(selected, (val) => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
>
|
||||
<template #display="{ setSearch }">
|
||||
<div class="search-input">
|
||||
<i class="search-input-icon fas fa-search"></i>
|
||||
<search-icon class="search-input-icon" />
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<template #option="{ option }">
|
||||
<div class="option-container">
|
||||
<div class="option-main">
|
||||
<template v-if="option.icon">
|
||||
<template v-if="option.smallIcon || option.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(option.icon)"
|
||||
:src="option.icon"
|
||||
v-if="isImageIcon(option.smallIcon || option.icon)"
|
||||
:src="option.smallIcon || option.icon"
|
||||
class="option-icon"
|
||||
:alt="option.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', option.icon]"></i>
|
||||
<component v-else :is="option.smallIcon || option.icon" class="option-icon" />
|
||||
</template>
|
||||
<span>{{ option.name }}</span>
|
||||
<span class="option-count" v-if="option.count > 0"> x {{ option.count }}</span>
|
||||
|
||||
183
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="third-party-auth">
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.name"
|
||||
class="third-party-button"
|
||||
:class="provider.name"
|
||||
@click="provider.action"
|
||||
>
|
||||
<img
|
||||
class="third-party-button-icon"
|
||||
:class="provider.name"
|
||||
:src="provider.icon"
|
||||
:alt="provider.alt"
|
||||
/>
|
||||
<div class="third-party-button-text" :class="provider.name">
|
||||
{{ provider.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import googleIcon from '~/assets/icons/google.svg'
|
||||
import githubIcon from '~/assets/icons/github.svg'
|
||||
import discordIcon from '~/assets/icons/discord.svg'
|
||||
import twitterIcon from '~/assets/icons/twitter.svg'
|
||||
import telegramIcon from '~/assets/icons/telegram.svg'
|
||||
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import { telegramAuthorize } from '~/utils/telegram'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'login',
|
||||
},
|
||||
inviteToken: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const actionText = computed(() => (props.mode === 'signup' ? '注册' : '登录'))
|
||||
|
||||
const providers = computed(() => [
|
||||
{
|
||||
name: 'google',
|
||||
icon: googleIcon,
|
||||
action: () => googleAuthorize(props.inviteToken),
|
||||
alt: 'Google Logo',
|
||||
label: `Google ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
icon: githubIcon,
|
||||
action: () => githubAuthorize(props.inviteToken),
|
||||
alt: 'GitHub Logo',
|
||||
label: `GitHub ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'discord',
|
||||
icon: discordIcon,
|
||||
action: () => discordAuthorize(props.inviteToken),
|
||||
alt: 'Discord Logo',
|
||||
label: `Discord ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'twitter',
|
||||
icon: twitterIcon,
|
||||
action: () => twitterAuthorize(props.inviteToken),
|
||||
alt: 'Twitter Logo',
|
||||
label: `X ${actionText.value}`,
|
||||
},
|
||||
{
|
||||
name: 'telegram',
|
||||
icon: telegramIcon,
|
||||
action: () => telegramAuthorize(props.inviteToken),
|
||||
alt: 'Telegram Logo',
|
||||
label: `Telegram ${actionText.value}`,
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.third-party-auth {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.third-party-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.third-party-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.third-party-button-text.twitter {
|
||||
color: rgb(182, 182, 182);
|
||||
}
|
||||
|
||||
/* Provider specific classes for customization */
|
||||
.third-party-button.google {
|
||||
background-color: var(--google-bg, var(--login-background-color));
|
||||
color: var(--google-color, inherit);
|
||||
}
|
||||
.third-party-button.google:hover {
|
||||
background-color: var(--google-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.github {
|
||||
background-color: var(--github-bg, var(--login-background-color));
|
||||
color: var(--github-color, inherit);
|
||||
}
|
||||
.third-party-button.github:hover {
|
||||
background-color: var(--github-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.discord {
|
||||
background-color: var(--discord-bg, var(--login-background-color));
|
||||
color: var(--discord-color, inherit);
|
||||
}
|
||||
.third-party-button.discord:hover {
|
||||
background-color: var(--discord-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.twitter {
|
||||
background-color: var(--twitter-bg, var(--login-background-color));
|
||||
color: var(--twitter-color, inherit);
|
||||
}
|
||||
.third-party-button.twitter:hover {
|
||||
background-color: var(--twitter-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
.third-party-button.telegram {
|
||||
background-color: var(--telegram-bg, var(--login-background-color));
|
||||
color: var(--telegram-color, inherit);
|
||||
}
|
||||
.third-party-button.telegram:hover {
|
||||
background-color: var(--telegram-bg-hover, var(--login-background-color-hover));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.third-party-auth {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.third-party-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="user-list">
|
||||
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="fas fa-inbox" />
|
||||
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="inbox" />
|
||||
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)">
|
||||
<BaseImage :src="u.avatar" alt="avatar" class="user-avatar" />
|
||||
<div class="user-info">
|
||||
|
||||
@@ -3,82 +3,73 @@ import { useWebSocket } from './useWebSocket'
|
||||
import { getToken } from '~/utils/auth'
|
||||
|
||||
const count = ref(0)
|
||||
let isInitialized = false
|
||||
let wsSubscription = null
|
||||
let isInitialized = false;
|
||||
|
||||
export function useChannelsUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const { subscribe, isConnected, connect } = useWebSocket()
|
||||
const config = useRuntimeConfig();
|
||||
const API_BASE_URL = config.public.apiBaseUrl;
|
||||
const { subscribe, isConnected, connect } = useWebSocket();
|
||||
|
||||
const fetchChannelUnread = async () => {
|
||||
const token = getToken()
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
count.value = data
|
||||
const data = await response.json();
|
||||
count.value = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch channel unread count:', e)
|
||||
console.error('Failed to fetch channel unread count:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setupWebSocketListener = () => {
|
||||
const destination = '/user/queue/channel-unread';
|
||||
|
||||
subscribe(destination, (message) => {
|
||||
const unread = parseInt(message.body, 10);
|
||||
if (!isNaN(unread)) {
|
||||
count.value = unread;
|
||||
}
|
||||
}).then(subscription => {
|
||||
if (subscription) {
|
||||
console.log('频道未读消息订阅成功');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
const token = getToken()
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0
|
||||
return
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
}
|
||||
|
||||
const setupWebSocketListener = () => {
|
||||
if (!wsSubscription) {
|
||||
watch(
|
||||
isConnected,
|
||||
(newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
|
||||
const unread = parseInt(message.body, 10)
|
||||
if (!isNaN(unread)) {
|
||||
count.value = unread
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
}
|
||||
|
||||
fetchChannelUnread();
|
||||
setupWebSocketListener();
|
||||
};
|
||||
|
||||
const setFromList = (channels) => {
|
||||
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
|
||||
}
|
||||
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0;
|
||||
};
|
||||
|
||||
const hasUnread = computed(() => count.value > 0)
|
||||
const hasUnread = computed(() => count.value > 0);
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true
|
||||
initialize()
|
||||
} else {
|
||||
fetchChannelUnread()
|
||||
if (!isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
setupWebSocketListener()
|
||||
if (!isInitialized) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
isInitialized = true;
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,5 +79,5 @@ export function useChannelsUnreadCount() {
|
||||
fetchChannelUnread,
|
||||
initialize,
|
||||
setFromList,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
51
frontend_nuxt/composables/useCountdown.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
/**
|
||||
* 通用倒计时 composable
|
||||
* @param endTime 截止时间字符串或 Date 对象
|
||||
* @returns { countdown, isEnded }
|
||||
*/
|
||||
export function useCountdown(endTime?: string | Date) {
|
||||
const countdown = ref('')
|
||||
const isEnded = ref(false)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const update = () => {
|
||||
if (!endTime) {
|
||||
countdown.value = ''
|
||||
isEnded.value = true
|
||||
return
|
||||
}
|
||||
const diff = new Date(endTime).getTime() - Date.now()
|
||||
if (diff <= 0) {
|
||||
countdown.value = '已结束'
|
||||
isEnded.value = true
|
||||
if (timer) clearInterval(timer)
|
||||
return
|
||||
}
|
||||
// 计算天、时、分、秒
|
||||
const days = Math.floor(diff / (24 * 3600 * 1000))
|
||||
const hours = Math.floor((diff % (24 * 3600 * 1000)) / 3600000)
|
||||
const minutes = Math.floor((diff % 3600000) / 60000)
|
||||
const seconds = Math.floor((diff % 60000) / 1000)
|
||||
|
||||
if (days > 0) {
|
||||
countdown.value = `${days}天 ${hours}小时 ${minutes}分 ${seconds}秒`
|
||||
} else if (hours > 0) {
|
||||
countdown.value = `${hours}小时 ${minutes}分 ${seconds}秒`
|
||||
} else {
|
||||
countdown.value = `${minutes}分 ${seconds}秒`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
timer = setInterval(update, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
return { countdown, isEnded }
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { getToken } from '~/utils/auth';
|
||||
|
||||
const count = ref(0);
|
||||
let isInitialized = false;
|
||||
let wsSubscription = null;
|
||||
|
||||
export function useUnreadCount() {
|
||||
const config = useRuntimeConfig();
|
||||
@@ -30,64 +29,48 @@ export function useUnreadCount() {
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
const setupWebSocketListener = () => {
|
||||
console.log('设置未读消息订阅...');
|
||||
const destination = '/user/queue/unread-count';
|
||||
|
||||
subscribe(destination, (message) => {
|
||||
const unreadCount = parseInt(message.body, 10);
|
||||
if (!isNaN(unreadCount)) {
|
||||
count.value = unreadCount;
|
||||
}
|
||||
}).then(subscription => {
|
||||
if (subscription) {
|
||||
console.log('未读消息订阅成功');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 总是获取最新的未读数量
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
|
||||
// 设置WebSocket监听
|
||||
await setupWebSocketListener();
|
||||
fetchUnreadCount();
|
||||
setupWebSocketListener();
|
||||
};
|
||||
|
||||
const setupWebSocketListener = async () => {
|
||||
// 只有在还没有订阅的情况下才设置监听
|
||||
if (!wsSubscription) {
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
const destination = `/user/queue/unread-count`;
|
||||
wsSubscription = subscribe(destination, (message) => {
|
||||
const unreadCount = parseInt(message.body, 10);
|
||||
if (!isNaN(unreadCount)) {
|
||||
count.value = unreadCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
// 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
if (!isInitialized) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
isInitialized = true;
|
||||
initialize(); // 完整初始化,包括WebSocket监听
|
||||
} else {
|
||||
// 即使已经初始化,也要确保获取最新的未读数量并确保WebSocket监听存在
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接和监听都存在
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
setupWebSocketListener();
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
fetchUnreadCount,
|
||||
initialize,
|
||||
initialize,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +1,182 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, readonly, watch } from 'vue'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import SockJS from 'sockjs-client/dist/sockjs.min.js'
|
||||
import { useRuntimeConfig } from '#app'
|
||||
|
||||
const client = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const activeSubscriptions = ref(new Map())
|
||||
// Store callbacks to allow for re-subscription after reconnect
|
||||
const resubscribeCallbacks = new Map()
|
||||
|
||||
// Helper for unified subscription logging
|
||||
const logSubscriptionActivity = (action, destination, subscriptionId = 'N/A') => {
|
||||
console.log(
|
||||
`[SUB_MAN] ${action} | Dest: ${destination} | SubID: ${subscriptionId} | Active: ${activeSubscriptions.value.size}`
|
||||
)
|
||||
}
|
||||
|
||||
const connect = (token) => {
|
||||
if (isConnected.value) {
|
||||
if (isConnected.value || (client.value && client.value.active)) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const socketUrl = `${API_BASE_URL}/api/sockjs`
|
||||
|
||||
const socket = new SockJS(socketUrl)
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSOCKET_URL = config.public.websocketUrl
|
||||
const socketUrl = `${WEBSOCKET_URL}/api/sockjs`
|
||||
|
||||
const stompClient = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
webSocketFactory: () => new SockJS(socketUrl),
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
debug: function (str) {},
|
||||
reconnectDelay: 5000,
|
||||
debug: function (str) {
|
||||
|
||||
},
|
||||
reconnectDelay: 10000,
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
})
|
||||
|
||||
stompClient.onConnect = (frame) => {
|
||||
isConnected.value = true
|
||||
resubscribeCallbacks.forEach((callback, destination) => {
|
||||
doSubscribe(destination, callback)
|
||||
})
|
||||
}
|
||||
|
||||
stompClient.onStompError = (frame) => {
|
||||
console.error('WebSocket STOMP error:', frame)
|
||||
console.error('Full frame:', frame)
|
||||
}
|
||||
|
||||
stompClient.onWebSocketError = (event) => {
|
||||
|
||||
}
|
||||
|
||||
stompClient.onWebSocketClose = (event) => {
|
||||
isConnected.value = false;
|
||||
activeSubscriptions.value.clear();
|
||||
logSubscriptionActivity('Cleared all subscriptions due to WebSocket close', 'N/A');
|
||||
};
|
||||
|
||||
stompClient.onDisconnect = (frame) => {
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
stompClient.activate()
|
||||
client.value = stompClient
|
||||
}
|
||||
|
||||
const unsubscribe = (destination) => {
|
||||
if (!destination) {
|
||||
return false
|
||||
}
|
||||
const subscription = activeSubscriptions.value.get(destination)
|
||||
if (subscription) {
|
||||
try {
|
||||
subscription.unsubscribe()
|
||||
logSubscriptionActivity('Unsubscribed', destination, subscription.id)
|
||||
} catch (e) {
|
||||
console.error(`Error during unsubscribe for ${destination}:`, e)
|
||||
} finally {
|
||||
activeSubscriptions.value.delete(destination)
|
||||
resubscribeCallbacks.delete(destination)
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeAll = () => {
|
||||
logSubscriptionActivity('Unsubscribing from ALL', `Total: ${activeSubscriptions.value.size}`)
|
||||
const destinations = [...activeSubscriptions.value.keys()]
|
||||
destinations.forEach(dest => {
|
||||
unsubscribe(dest)
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
unsubscribeAll()
|
||||
if (client.value) {
|
||||
isConnected.value = false
|
||||
client.value.deactivate()
|
||||
try {
|
||||
client.value.deactivate()
|
||||
} catch (e) {
|
||||
console.error('Error during client deactivation:', e)
|
||||
}
|
||||
client.value = null
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doSubscribe = (destination, callback) => {
|
||||
try {
|
||||
if (!client.value || !client.value.connected) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (activeSubscriptions.value.has(destination)) {
|
||||
unsubscribe(destination)
|
||||
}
|
||||
|
||||
const subscription = client.value.subscribe(destination, (message) => {
|
||||
callback(message)
|
||||
})
|
||||
|
||||
if (subscription) {
|
||||
activeSubscriptions.value.set(destination, subscription)
|
||||
resubscribeCallbacks.set(destination, callback) // Store for re-subscription
|
||||
logSubscriptionActivity('Subscribed', destination, subscription.id)
|
||||
return subscription
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Exception during subscription to ${destination}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = (destination, callback) => {
|
||||
if (!isConnected.value || !client.value || !client.value.connected) {
|
||||
return null
|
||||
if (!destination) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = client.value.subscribe(destination, (message) => {
|
||||
try {
|
||||
if (
|
||||
destination.includes('/queue/unread-count') ||
|
||||
destination.includes('/queue/channel-unread')
|
||||
) {
|
||||
callback(message)
|
||||
} else {
|
||||
const parsedMessage = JSON.parse(message.body)
|
||||
callback(parsedMessage)
|
||||
return new Promise((resolve) => {
|
||||
if (client.value && client.value.connected) {
|
||||
const sub = doSubscribe(destination, callback)
|
||||
resolve(sub)
|
||||
} else {
|
||||
const unwatch = watch(isConnected, (newVal) => {
|
||||
if (newVal) {
|
||||
setTimeout(() => {
|
||||
const sub = doSubscribe(destination, callback)
|
||||
unwatch()
|
||||
resolve(sub)
|
||||
}, 100)
|
||||
}
|
||||
} catch (error) {
|
||||
callback(message)
|
||||
}
|
||||
})
|
||||
|
||||
return subscription
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
setTimeout(() => {
|
||||
unwatch()
|
||||
if (!isConnected.value) {
|
||||
resolve(null)
|
||||
}
|
||||
}, 15000)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
return {
|
||||
client,
|
||||
client: readonly(client),
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
unsubscribeAll,
|
||||
activeSubscriptions: readonly(activeSubscriptions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,21 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: 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 || '',
|
||||
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
|
||||
},
|
||||
},
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
css: [
|
||||
'vditor/dist/index.css',
|
||||
'~/assets/fonts.css',
|
||||
'~/assets/global.css',
|
||||
'@icon-park/vue-next/styles/index.css',
|
||||
],
|
||||
app: {
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
head: {
|
||||
@@ -70,11 +77,11 @@ export default defineNuxtConfig({
|
||||
rel: 'manifest',
|
||||
href: '/manifest.webmanifest',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||
referrerpolicy: 'no-referrer',
|
||||
},
|
||||
// {
|
||||
// rel: 'stylesheet',
|
||||
// href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css',
|
||||
// referrerpolicy: 'no-referrer',
|
||||
// },
|
||||
],
|
||||
},
|
||||
baseURL: '/',
|
||||
|
||||
17
frontend_nuxt/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "frontend_nuxt",
|
||||
"dependencies": {
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
@@ -25,6 +26,9 @@
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-flatpickr-component": "^12.0.0",
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -990,6 +994,19 @@
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@icon-park/vue-next": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@icon-park/vue-next/-/vue-next-1.4.2.tgz",
|
||||
"integrity": "sha512-+QklF255wkfBOabY+xw6FAI0Bwln/RhdwCunNy/9sKdKuChtaU67QZqU67KGAvZUTeeBgsL+yaHHxqfQeGZXEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 8.0.0",
|
||||
"npm": ">= 5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "frontend_nuxt",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nuxt dev",
|
||||
"build": "nuxt build",
|
||||
@@ -9,6 +12,7 @@
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@icon-park/vue-next": "^1.4.2",
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="activity-list-page-card-content">{{ a.content }}</div>
|
||||
<div class="activity-list-page-card-footer">
|
||||
<div class="activity-list-page-card-footer-start-time">
|
||||
<i class="fas fa-clock"></i>
|
||||
<stopwatch />
|
||||
<span>开始于 {{ TimeManager.format(a.startTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
<div class="forgot-page">
|
||||
<div class="forgot-content">
|
||||
<div class="forgot-title">找回密码</div>
|
||||
|
||||
|
||||
<div v-if="step === 0" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="email" placeholder="邮箱" />
|
||||
<BaseInput icon="mail" v-model="email" placeholder="邮箱" />
|
||||
<div v-if="emailError" class="error-message">{{ emailError }}</div>
|
||||
<div class="primary-button" @click="sendCode" v-if="!isSending">发送验证码</div>
|
||||
<div class="primary-button disabled" v-else>发送中...</div>
|
||||
</div>
|
||||
<div v-else-if="step === 1" class="step-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="code" placeholder="邮箱验证码" />
|
||||
<BaseInput icon="mail" v-model="code" placeholder="邮箱验证码" />
|
||||
<div class="primary-button" @click="verifyCode" v-if="!isVerifying">验证</div>
|
||||
<div class="primary-button disabled" v-else>验证中...</div>
|
||||
</div>
|
||||
<div v-else class="step-content">
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<BaseInput icon="lock" v-model="password" type="password" placeholder="新密码" />
|
||||
<div v-if="passwordError" class="error-message">{{ passwordError }}</div>
|
||||
<div class="primary-button" @click="resetPassword" v-if="!isResetting">重置密码</div>
|
||||
<div class="primary-button disabled" v-else>提交中...</div>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
使用 Google 注册的用户可使用对应的邮箱进行找回密码
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,12 +68,10 @@
|
||||
>
|
||||
<div class="article-main-container">
|
||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||
<i v-if="article.type === 'LOTTERY'" class="fa-solid fa-gift lottery-icon"></i>
|
||||
<i
|
||||
v-else-if="article.type === 'POLL'"
|
||||
class="fa-solid fa-square-poll-vertical poll-icon"
|
||||
></i>
|
||||
<pin v-if="article.pinned" theme="outline" class="pinned-icon" />
|
||||
<gift v-if="article.type === 'LOTTERY'" class="lottery-icon" />
|
||||
<ranking-list v-else-if="article.type === 'POLL'" class="poll-icon" />
|
||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||
{{ article.title }}
|
||||
</NuxtLink>
|
||||
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||
@@ -140,7 +138,6 @@ import { getToken } from '~/utils/auth'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
useHead({
|
||||
title: 'OpenIsle - 全面开源的自由社区',
|
||||
meta: [
|
||||
@@ -291,6 +288,7 @@ const {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -332,6 +330,7 @@ const fetchNextPage = async () => {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
rssExcluded: p.rssExcluded || false,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
@@ -547,11 +546,16 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
|
||||
.pinned-icon,
|
||||
.lottery-icon,
|
||||
.featured-icon,
|
||||
.poll-icon {
|
||||
margin-right: 4px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.featured-icon {
|
||||
color: var(--featured-color);
|
||||
}
|
||||
|
||||
.article-item-description {
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
|
||||
<div class="email-login-page-content">
|
||||
<BaseInput icon="fas fa-envelope" v-model="username" placeholder="邮箱/用户名" />
|
||||
<BaseInput icon="mail" v-model="username" placeholder="邮箱/用户名" />
|
||||
|
||||
<BaseInput icon="fas fa-lock" v-model="password" type="password" placeholder="密码" />
|
||||
<BaseInput icon="lock" v-model="password" type="password" placeholder="密码" />
|
||||
|
||||
<div v-if="!isWaitingForLogin" class="login-page-button-primary" @click="submitLogin">
|
||||
<div class="login-page-button-text">登录</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div v-else class="login-page-button-primary disabled">
|
||||
<div class="login-page-button-text">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<loading-four />
|
||||
登录中...
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,41 +28,21 @@
|
||||
>
|
||||
</div>
|
||||
<div class="hint-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<info-icon />
|
||||
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="other-login-page-content">
|
||||
<div class="login-page-button" @click="loginWithGoogle">
|
||||
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
|
||||
<div class="login-page-button-text">Google 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithGithub">
|
||||
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
|
||||
<div class="login-page-button-text">GitHub 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithDiscord">
|
||||
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
|
||||
<div class="login-page-button-text">Discord 登录</div>
|
||||
</div>
|
||||
<div class="login-page-button" @click="loginWithTwitter">
|
||||
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
|
||||
<div class="login-page-button-text">Twitter 登录</div>
|
||||
</div>
|
||||
</div>
|
||||
<ThirdPartyAuth mode="login" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||
import { registerPush } from '~/utils/push'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -105,19 +85,6 @@ const submitLogin = async () => {
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
googleAuthorize()
|
||||
}
|
||||
const loginWithGithub = () => {
|
||||
githubAuthorize()
|
||||
}
|
||||
const loginWithDiscord = () => {
|
||||
discordAuthorize()
|
||||
}
|
||||
const loginWithTwitter = () => {
|
||||
twitterAuthorize()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -190,16 +157,6 @@ const loginWithTwitter = () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30%;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-primary {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
@@ -229,29 +186,6 @@ const loginWithTwitter = () => {
|
||||
background-color: var(--primary-color-disabled);
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
min-width: 150px;
|
||||
background-color: var(--login-background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button:hover {
|
||||
background-color: var(--login-background-color-hover);
|
||||
}
|
||||
|
||||
.login-page-button-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.login-page-button-text {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -293,16 +227,5 @@ const loginWithTwitter = () => {
|
||||
margin-top: 0px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.other-login-page-content {
|
||||
margin-top: 20px;
|
||||
margin-left: 0px;
|
||||
width: calc(100% - 40px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.login-page-button {
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||