mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-22 18:31:14 +08:00
Compare commits
48 Commits
codex/upda
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e58a5741 | ||
|
|
a68c925c68 | ||
|
|
4f248e8a71 | ||
|
|
277883f9d9 | ||
|
|
e9e996f291 | ||
|
|
a8667ce5e9 | ||
|
|
0d316af22a | ||
|
|
f8e13af672 | ||
|
|
92d90c997c | ||
|
|
303ec9b6c1 | ||
|
|
90eafe27fd | ||
|
|
98e2ea7ef8 | ||
|
|
e3290f3431 | ||
|
|
160570574c | ||
|
|
cf7b667f30 | ||
|
|
60fa6051b7 | ||
|
|
1c0e90d32d | ||
|
|
a15065575d | ||
|
|
cb958e162e | ||
|
|
660d8ffe51 | ||
|
|
5509a1eead | ||
|
|
1acd776d3b | ||
|
|
53be8d943a | ||
|
|
9957042746 | ||
|
|
302f98f44e | ||
|
|
790c4db8ea | ||
|
|
bbb0a11d49 | ||
|
|
35340319c6 | ||
|
|
343c4d3793 | ||
|
|
87b214cbc0 | ||
|
|
e7f06787d2 | ||
|
|
d7d2fd5dcb | ||
|
|
76b65a1400 | ||
|
|
fa8ee113a2 | ||
|
|
181237adee | ||
|
|
1b8135acfb | ||
|
|
67bbe832a0 | ||
|
|
9d67f7d8d6 | ||
|
|
da0d26c8b5 | ||
|
|
81d64bfc7b | ||
|
|
3e255c1288 | ||
|
|
224e1a1018 | ||
|
|
4456997573 | ||
|
|
ef0f0d013b | ||
|
|
a83ddc40fe | ||
|
|
f36ed28185 | ||
|
|
1d31284dba | ||
|
|
995d68b50b |
23
.env.example
23
.env.example
@@ -80,26 +80,39 @@ WEBPUSH_PRIVATE_KEY=
|
|||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# === Frontend (Nuxt) ===
|
# === Frontend (Nuxt) ===
|
||||||
|
# 本地开发
|
||||||
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
NUXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||||
|
# 线上环境
|
||||||
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
# NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||||
|
# 测试环境
|
||||||
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
# NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
|
NUXT_PUBLIC_WEBSOCKET_URL=http://localhost:8082
|
||||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com
|
# 线上环境
|
||||||
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
|
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.open-isle.com/websocket
|
||||||
|
# 测试环境
|
||||||
|
# NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com/websocket
|
||||||
|
|
||||||
|
# 本地开发
|
||||||
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
|
NUXT_PUBLIC_WEBSITE_BASE_URL=http://localhost:3000
|
||||||
# 线上 & 本地均可使用
|
# 线上 & 测试 (www.staging.open-isle.com) & 本地均可使用
|
||||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
# 线上
|
# 线上
|
||||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
|
# 测试环境 (www.staging.open-isle.com)
|
||||||
|
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23li6GHPxx4MwipWnM
|
||||||
# 本地
|
# 本地
|
||||||
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
# NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liOlrZnPKRF7s7NN
|
||||||
|
|
||||||
# 线上 & 本地均可使用
|
# 线上 & 本地均可使用
|
||||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
|
|
||||||
# 线上 & 本地均可使用
|
# 线上 & 本地均可使用
|
||||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
|
|
||||||
# 线上
|
# 线上
|
||||||
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||||
|
# 测试环境 (www.staging.open-isle.com)
|
||||||
|
# NUXT_PUBLIC_TELEGRAM_BOT_ID=7832207011
|
||||||
|
|
||||||
|
|||||||
7
.github/workflows/deploy-docs.yml
vendored
7
.github/workflows/deploy-docs.yml
vendored
@@ -11,12 +11,17 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
# 文档发布自己的排队锁,不影响服务器部署
|
||||||
|
concurrency:
|
||||||
|
group: openisle-docs
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-docs:
|
build-docs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
|
|||||||
11
.github/workflows/deploy-staging.yml
vendored
11
.github/workflows/deploy-staging.yml
vendored
@@ -2,22 +2,27 @@ name: Staging CI & CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [ "main" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
# 与生产部署共用同一把锁,确保服务器上始终串行(跨工作流也互斥)
|
||||||
|
concurrency:
|
||||||
|
group: openisle-server
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: Deploy
|
environment: Deploy
|
||||||
if: ${{ !github.event.repository.fork }} # 只有非 fork 才执行
|
if: ${{ !github.event.repository.fork }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy to Server
|
- name: Deploy to Server (staging)
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SSH_HOST }}
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
|||||||
11
.github/workflows/deploy.yml
vendored
11
.github/workflows/deploy.yml
vendored
@@ -2,8 +2,13 @@ name: CI & CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
# schedule:
|
schedule:
|
||||||
# - cron: "0 19 * * *" # 每天 UTC 19:00,相当于北京时间凌晨3点
|
- cron: "0 19 * * *" # 每天 UTC 19:00(北京 03:00)
|
||||||
|
|
||||||
|
# 与 Staging 共用同一把锁,避免两边同时在 8G 服务器上跑
|
||||||
|
concurrency:
|
||||||
|
group: openisle-server
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -13,7 +18,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy to Server
|
- name: Deploy to Server (prod)
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
host: ${{ secrets.SSH_HOST }}
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ cd OpenIsle
|
|||||||
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server,适合需要全链路联调的场景。
|
想要最快速地同时体验前端和后端,可直接使用仓库提供的 Docker Compose。该方案会一次性拉起数据库、消息队列、搜索、后端、WebSocket 以及前端 Dev Server,适合需要全链路联调的场景。
|
||||||
|
|
||||||
1. 准备环境变量文件:
|
1. 准备环境变量文件:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
`.env.example` 是模板,可在 `.env` 中按需覆盖如端口、密钥等配置。确保 `NUXT_PUBLIC_API_BASE_URL`、`NUXT_PUBLIC_WEBSOCKET_URL` 等仍指向 `localhost`,方便前端直接访问容器映射端口。
|
||||||
|
|
||||||
2. 启动 Dev Profile:
|
2. 启动 Dev Profile:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose \
|
docker compose \
|
||||||
-f docker/docker-compose.yaml \
|
-f docker/docker-compose.yaml \
|
||||||
@@ -56,7 +52,6 @@ cd OpenIsle
|
|||||||
--env-file .env \
|
--env-file .env \
|
||||||
--profile dev up -d
|
--profile dev up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
该命令会创建名为 `frontend_dev` 的容器并运行 `npm run dev`,浏览器访问 http://127.0.0.1:3000 即可查看页面。
|
||||||
|
|
||||||
修改代码后,可以强制重新创建所有容器,执行:
|
修改代码后,可以强制重新创建所有容器,执行:
|
||||||
@@ -69,14 +64,11 @@ cd OpenIsle
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. 查看服务状态:
|
3. 查看服务状态:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
docker compose -f docker/docker-compose.yaml --env-file .env ps
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
|
docker compose -f docker/docker-compose.yaml --env-file .env logs -f frontend_dev
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 停止所有容器:
|
4. 停止所有容器:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||||
```
|
```
|
||||||
@@ -100,8 +92,13 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
|
|
||||||
#### 配置环境变量
|
#### 配置环境变量
|
||||||
|
|
||||||
1. 在 IDEA 中配置「Environment file」:将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `.env`,即可让 IDE 读取该文件。
|
1. 生成环境变量文件:
|
||||||
2. 需要调整端口或功能开关时,优先修改 `.env`,例如:
|
```shell
|
||||||
|
cp open-isle.env.example open-isle.env
|
||||||
|
```
|
||||||
|
`open-isle.env` 才是实际被读取的文件。可在其中补充数据库、第三方服务等配置,`open-isle.env` 已被 Git 忽略,放心修改。
|
||||||
|
2. 在 IDEA 中配置「Environment file」:将 `Run/Debug Configuration` 的 `Environment variables` 指向刚刚复制的 `open-isle.env`,即可让 IDE 读取该文件。
|
||||||
|
3. 需要调整端口或功能开关时,优先修改 `open-isle.env`,例如:
|
||||||
```ini
|
```ini
|
||||||
SERVER_PORT=8081
|
SERVER_PORT=8081
|
||||||
LOG_LEVEL=DEBUG
|
LOG_LEVEL=DEBUG
|
||||||
@@ -109,7 +106,7 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
|
|
||||||
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
也可以修改 `src/main/resources/application.properties`,但该文件会被 Git 追踪,通常不推荐。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### 配置 IDEA 参数
|
#### 配置 IDEA 参数
|
||||||
|
|
||||||
@@ -119,33 +116,15 @@ IDEA 打开 `backend/` 文件夹。
|
|||||||
-Dserver.port=8081
|
-Dserver.port=8081
|
||||||
```
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。
|
完成环境变量和运行参数设置后,即可启动 Spring Boot 应用。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 启动前端服务
|
## 前端连接预发或正式环境
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> **⚠️ 环境要求:Node.js 版本最低 20.0.0(因为 Nuxt 框架要求)**
|
|
||||||
|
|
||||||
```shell
|
|
||||||
cd frontend_nuxt/
|
|
||||||
```
|
|
||||||
|
|
||||||
安装依赖并启动开发服务器:
|
|
||||||
|
|
||||||
```shell
|
|
||||||
npm install --verbose
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
默认情况下,浏览器访问 http://127.0.0.1:3000 即可访问前端页面。
|
|
||||||
|
|
||||||
### 连接预发或正式环境
|
|
||||||
|
|
||||||
前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境:
|
前端默认读取 `.env` 中的接口地址,可通过修改以下变量快速切换到预发或正式环境:
|
||||||
|
|
||||||
@@ -155,10 +134,8 @@ npm run dev
|
|||||||
NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
NUXT_PUBLIC_API_BASE_URL=https://www.staging.open-isle.com
|
||||||
NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
|
NUXT_PUBLIC_WEBSOCKET_URL=https://www.staging.open-isle.com
|
||||||
```
|
```
|
||||||
|
|
||||||
将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等)可根据需求调整。
|
将 `staging` 替换为 `www` 即可连接正式环境。其他变量(如 OAuth Client ID、站点地址等)可根据需求调整。
|
||||||
|
|
||||||
2. 已经存在 `.env` 时,可直接编辑上述变量并重启 `npm run dev` 让配置生效。
|
|
||||||
|
|
||||||
## 其他配置
|
## 其他配置
|
||||||
|
|
||||||
@@ -166,41 +143,42 @@ npm run dev
|
|||||||
|
|
||||||
- 修改 `application.properties` 配置
|
- 修改 `application.properties` 配置
|
||||||
|
|
||||||

|

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

|

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

|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### 配置Resend邮箱服务
|
### 配置Resend邮箱服务
|
||||||
|
|
||||||
https://resend.com/emails 创建账号并登录
|
https://resend.com/emails 创建账号并登录
|
||||||
|
|
||||||
- `Domains` -> `Add Domain`
|
- `Domains` -> `Add Domain`
|
||||||

|

|
||||||
|
|
||||||
- 填写域名
|
- 填写域名
|
||||||

|

|
||||||
|
|
||||||
- 等待一段时间后解析成功,创建 key
|
- 等待一段时间后解析成功,创建 key
|
||||||
`API Keys` -> `Create API Key`,输入名称,设置 `Permission` 为 `Sending access`
|
`API Keys` -> `Create API Key`,输入名称,设置 `Permission` 为 `Sending access`
|
||||||
**Key 只能查看一次,务必保存下来**
|
**Key 只能查看一次,务必保存下来**
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
- 修改 `.env` 配置中的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL`
|
- 修改 `.env` 配置中的 `RESEND_API_KEY` 和 `RESEND_FROM_EMAIL`
|
||||||
`RESEND_FROM_EMAIL`: **noreply@域名**
|
`RESEND_FROM_EMAIL`: **noreply@域名**
|
||||||
`RESEND_API_KEY`:**刚刚复制的 Key**
|
`RESEND_API_KEY`:**刚刚复制的 Key**
|
||||||

|

|
||||||
|
|
||||||
## API文档
|
## API文档
|
||||||
|
|
||||||
### OpenAPI文档
|
### OpenAPI文档
|
||||||
|
|
||||||
https://docs.open-isle.com
|
https://docs.open-isle.com
|
||||||
|
|
||||||
### 部署时间线以及文档时效性
|
### 部署时间线以及文档时效性
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import io.swagger.v3.oas.annotations.media.Content;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -131,6 +132,7 @@ public class CommentController {
|
|||||||
c.getId(),
|
c.getId(),
|
||||||
"comment",
|
"comment",
|
||||||
c.getCreatedAt(),
|
c.getCreatedAt(),
|
||||||
|
c.getPinnedAt(),
|
||||||
c // payload 是 CommentDto
|
c // payload 是 CommentDto
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -145,17 +147,39 @@ public class CommentController {
|
|||||||
l.getId(),
|
l.getId(),
|
||||||
"log",
|
"log",
|
||||||
l.getTime(), // 注意字段名不一样
|
l.getTime(), // 注意字段名不一样
|
||||||
|
null,
|
||||||
l // payload 是 PostChangeLogDto
|
l // payload 是 PostChangeLogDto
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.toList()
|
.toList()
|
||||||
);
|
);
|
||||||
// 排序
|
// 排序
|
||||||
Comparator<TimelineItemDto<?>> comparator = Comparator.comparing(TimelineItemDto::getCreatedAt);
|
Comparator<TimelineItemDto<?>> pinnedOrderComparator = (a, b) -> {
|
||||||
|
LocalDateTime aPinned = a.getPinnedAt();
|
||||||
|
LocalDateTime bPinned = b.getPinnedAt();
|
||||||
|
if (aPinned == null && bPinned == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (aPinned == null) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (bPinned == null) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return bPinned.compareTo(aPinned);
|
||||||
|
};
|
||||||
|
|
||||||
|
Comparator<TimelineItemDto<?>> comparator = Comparator.<TimelineItemDto<?>, Boolean>comparing(
|
||||||
|
item -> item.getPinnedAt() == null
|
||||||
|
).thenComparing(pinnedOrderComparator);
|
||||||
|
|
||||||
|
Comparator<TimelineItemDto<?>> createdAtComparator = Comparator.comparing(
|
||||||
|
TimelineItemDto::getCreatedAt
|
||||||
|
);
|
||||||
if (CommentSort.NEWEST.equals(sort)) {
|
if (CommentSort.NEWEST.equals(sort)) {
|
||||||
comparator = comparator.reversed();
|
createdAtComparator = createdAtComparator.reversed();
|
||||||
}
|
}
|
||||||
itemDtoList.sort(comparator);
|
itemDtoList.sort(comparator.thenComparing(createdAtComparator));
|
||||||
log.debug("listComments returning {} comments", itemDtoList.size());
|
log.debug("listComments returning {} comments", itemDtoList.size());
|
||||||
return itemDtoList;
|
return itemDtoList;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,6 @@ public class TimelineItemDto<T> {
|
|||||||
private Long id;
|
private Long id;
|
||||||
private String kind; // "comment" | "log"
|
private String kind; // "comment" | "log"
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime pinnedAt;
|
||||||
private T payload; // 泛型,具体类型由外部决定
|
private T payload; // 泛型,具体类型由外部决定
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ services:
|
|||||||
websocket-service:
|
websocket-service:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
profiles: ["staging", "prod"]
|
||||||
|
|
||||||
|
|
||||||
loopback_8080:
|
loopback_8080:
|
||||||
image: alpine/socat
|
image: alpine/socat
|
||||||
|
|||||||
@@ -179,7 +179,9 @@ body {
|
|||||||
|
|
||||||
.info-content-text pre .line-numbers {
|
.info-content-text pre .line-numbers {
|
||||||
counter-reset: line-number 0;
|
counter-reset: line-number 0;
|
||||||
width: 2em;
|
white-space: nowrap; /* 禁止数字换行 */
|
||||||
|
font-variant-numeric: tabular-nums; /* 数字等宽 */
|
||||||
|
/* width: 2em; */
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -203,6 +205,7 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: var(--code-highlight-background-color);
|
background-color: var(--code-highlight-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
white-space: pre; /* 禁止自动换行 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
@@ -341,6 +344,16 @@ body {
|
|||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*处理iframe视频标签*/
|
||||||
|
.info-content-text iframe {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 16 / 9; /* 保持 16:9 比例 */
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.d2h-file-name {
|
.d2h-file-name {
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
|
|||||||
157
frontend_nuxt/components/BaseItemGroup.vue
Normal file
157
frontend_nuxt/components/BaseItemGroup.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="base-item-group"
|
||||||
|
:style="{
|
||||||
|
width: `${containerWidth}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
'--base-item-group-duration': `${animationDuration}ms`,
|
||||||
|
}"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="itemKey(item, index)"
|
||||||
|
class="base-item-group__item"
|
||||||
|
:style="{
|
||||||
|
width: `${itemSize}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
transform: `translateX(${index * activeGap}px)`,
|
||||||
|
zIndex: items.length - index,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot :item="item" :index="index">
|
||||||
|
<BaseImage
|
||||||
|
v-if="item && (item.src || typeof item === 'string')"
|
||||||
|
class="base-item-group__image"
|
||||||
|
:src="typeof item === 'string' ? item : item.src"
|
||||||
|
:alt="itemAlt(item, index)"
|
||||||
|
/>
|
||||||
|
<div v-else class="base-item-group__placeholder">{{ placeholderText(item) }}</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
itemSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
collapsedGap: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
expandedGap: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
animationDuration: {
|
||||||
|
type: Number,
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
itemKeyField: {
|
||||||
|
type: String,
|
||||||
|
default: 'id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHovered = ref(false)
|
||||||
|
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
isHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
isHovered.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveExpandedGap = computed(() =>
|
||||||
|
props.expandedGap == null ? props.itemSize : props.expandedGap,
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeGap = computed(() =>
|
||||||
|
isHovered.value ? effectiveExpandedGap.value : props.collapsedGap,
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerWidth = computed(() =>
|
||||||
|
props.items.length ? props.itemSize + (props.items.length - 1) * activeGap.value : props.itemSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (effectiveExpandedGap.value < props.collapsedGap) {
|
||||||
|
console.warn('[BaseItemGroup] `expandedGap` should be greater than or equal to `collapsedGap`.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemKey = (item, index) => {
|
||||||
|
if (item && typeof item === 'object' && props.itemKeyField in item) {
|
||||||
|
return item[props.itemKeyField]
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemAlt = (item, index) => {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return item.alt || `item-${index}`
|
||||||
|
}
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return `item-${index}`
|
||||||
|
}
|
||||||
|
return 'item'
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderText = (item) => {
|
||||||
|
if (item == null) return ''
|
||||||
|
if (typeof item === 'object' && 'text' in item) return item.text
|
||||||
|
return String(item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-item-group {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
transition: width var(--base-item-group-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__item {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-neutral-100, #f0f2f5);
|
||||||
|
transition: transform var(--base-item-group-duration) ease;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-neutral-500, #666);
|
||||||
|
background-color: var(--color-neutral-200, #e5e7eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,14 +53,29 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
<ReactionsGroup
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
ref="commentReactionsGroupRef"
|
||||||
|
v-model="comment.reactions"
|
||||||
|
content-type="comment"
|
||||||
|
:content-id="comment.id"
|
||||||
|
/>
|
||||||
|
<div class="comment-reaction-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: commentLikedByMe }"
|
||||||
|
@click="toggleCommentLike"
|
||||||
|
>
|
||||||
|
<like v-if="!commentLikedByMe" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-action comment-reaction" @click="toggleEditor">
|
||||||
<comment-icon />
|
<comment-icon />
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
<div class="reaction-action copy-link" @click="copyCommentLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
|
|||||||
const lightboxIndex = ref(0)
|
const lightboxIndex = ref(0)
|
||||||
const lightboxImgs = ref([])
|
const lightboxImgs = ref([])
|
||||||
const loggedIn = computed(() => authState.loggedIn)
|
const loggedIn = computed(() => authState.loggedIn)
|
||||||
|
const commentReactionsGroupRef = ref(null)
|
||||||
|
const commentLikeCount = computed(
|
||||||
|
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
|
||||||
|
)
|
||||||
|
const commentLikedByMe = computed(() =>
|
||||||
|
(props.comment.reactions || []).some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const toggleCommentLike = () => {
|
||||||
|
commentReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
const isCommentFromPostAuthor = computed(() => {
|
const isCommentFromPostAuthor = computed(() => {
|
||||||
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.comment-reaction-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 18px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.reply-toggle {
|
.reply-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-reaction:hover {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
animation: highlight 2s;
|
animation: highlight 2s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@
|
|||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
v-if="
|
||||||
|
open &&
|
||||||
|
!isMobile &&
|
||||||
|
(loading || filteredOptions.length > 0 || showSearch || (remote && search))
|
||||||
|
"
|
||||||
:class="['dropdown-menu', menuClass]"
|
:class="['dropdown-menu', menuClass]"
|
||||||
v-click-outside="close"
|
v-click-outside="close"
|
||||||
ref="menuRef"
|
ref="menuRef"
|
||||||
@@ -62,26 +66,29 @@
|
|||||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
|
||||||
v-for="o in filteredOptions"
|
<template v-else>
|
||||||
:key="o.id"
|
<div
|
||||||
@click="select(o.id)"
|
v-for="o in filteredOptions"
|
||||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
:key="o.id"
|
||||||
>
|
@click="select(o.id)"
|
||||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||||
<template v-if="o.icon">
|
>
|
||||||
<BaseImage
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
v-if="isImageIcon(o.icon)"
|
<template v-if="o.icon">
|
||||||
:src="o.icon"
|
<BaseImage
|
||||||
class="option-icon"
|
v-if="isImageIcon(o.icon)"
|
||||||
:alt="o.name"
|
:src="o.icon"
|
||||||
/>
|
class="option-icon"
|
||||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
:alt="o.name"
|
||||||
</template>
|
/>
|
||||||
<span>{{ o.name }}</span>
|
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||||
</slot>
|
</template>
|
||||||
</div>
|
<span>{{ o.name }}</span>
|
||||||
<slot name="footer" :close="close" :loading="loading" />
|
</slot>
|
||||||
|
</div>
|
||||||
|
<slot name="footer" :close="close" :loading="loading" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
@@ -99,26 +106,29 @@
|
|||||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
|
||||||
v-for="o in filteredOptions"
|
<template v-else>
|
||||||
:key="o.id"
|
<div
|
||||||
@click="select(o.id)"
|
v-for="o in filteredOptions"
|
||||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
:key="o.id"
|
||||||
>
|
@click="select(o.id)"
|
||||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||||
<template v-if="o.icon">
|
>
|
||||||
<BaseImage
|
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||||
v-if="isImageIcon(o.icon)"
|
<template v-if="o.icon">
|
||||||
:src="o.icon"
|
<BaseImage
|
||||||
class="option-icon"
|
v-if="isImageIcon(o.icon)"
|
||||||
:alt="o.name"
|
:src="o.icon"
|
||||||
/>
|
class="option-icon"
|
||||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
:alt="o.name"
|
||||||
</template>
|
/>
|
||||||
<span>{{ o.name }}</span>
|
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||||
</slot>
|
</template>
|
||||||
</div>
|
<span>{{ o.name }}</span>
|
||||||
<slot name="footer" :close="close" :loading="loading" />
|
</slot>
|
||||||
|
</div>
|
||||||
|
<slot name="footer" :close="close" :loading="loading" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,6 +293,7 @@ export default {
|
|||||||
isImageIcon,
|
isImageIcon,
|
||||||
setSearch,
|
setSearch,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
remote: props.remote,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -384,6 +395,13 @@ export default {
|
|||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-empty {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted-text-color, #8c8c8c);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-mobile-page {
|
.dropdown-mobile-page {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -26,43 +26,59 @@
|
|||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div class="header-content-right">
|
<div class="header-content-right">
|
||||||
<div v-if="isMobile" class="search-icon" @click="search">
|
<!-- 搜索 -->
|
||||||
<search-icon />
|
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||||
</div>
|
<div class="header-icon-item" @click="search">
|
||||||
|
<search-icon class="header-icon" />
|
||||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
<span class="header-label">搜索</span>
|
||||||
<component :is="iconClass" />
|
</div>
|
||||||
</div>
|
</ToolTip>
|
||||||
|
<!-- 主题切换 -->
|
||||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
<ToolTip v-if="isMobile" content="切换主题" placement="bottom">
|
||||||
<copy />
|
<div class="header-icon-item" @click="cycleTheme">
|
||||||
邀请
|
<component :is="iconClass" class="header-icon" />
|
||||||
<loading v-if="isCopying" />
|
<span class="header-label">主题</span>
|
||||||
</div>
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
<!-- 邀请 -->
|
||||||
|
<ToolTip v-if="!isMobile" content="邀请好友" placement="bottom">
|
||||||
|
<div class="header-icon-item" @click="copyInviteLink">
|
||||||
|
<template v-if="!isCopying">
|
||||||
|
<copy-link class="header-icon" />
|
||||||
|
<span class="header-label">邀请</span>
|
||||||
|
</template>
|
||||||
|
<loading v-else />
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
<!-- 在线人数 -->
|
||||||
<ToolTip v-if="!isMobile" content="当前在线人数" placement="bottom">
|
<ToolTip v-if="!isMobile" content="当前在线人数" placement="bottom">
|
||||||
<div class="online-count">
|
<div class="header-icon-item">
|
||||||
<peoples-two />
|
<peoples-two class="header-icon" />
|
||||||
<span>{{ onlineCount }}</span>
|
<span class="header-label">在线</span>
|
||||||
|
<span class="header-badge">{{ onlineCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
<!-- RSS -->
|
||||||
<ToolTip content="复制RSS链接" placement="bottom">
|
<ToolTip content="复制RSS链接" placement="bottom">
|
||||||
<div class="rss-icon" @click="copyRssLink">
|
<div class="header-icon-item" @click="copyRssLink">
|
||||||
<rss />
|
<rss class="header-icon" />
|
||||||
|
<span class="header-label">RSS</span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
<!-- 发帖 -->
|
||||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||||
<div class="new-post-icon" @click="goToNewPost">
|
<div class="header-icon-item" @click="goToNewPost">
|
||||||
<edit />
|
<edit class="header-icon" />
|
||||||
|
<span class="header-label">发帖</span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|
||||||
|
<!-- 消息 -->
|
||||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||||
<div class="messages-icon" @click="goToMessages">
|
<div class="header-icon-item" @click="goToMessages">
|
||||||
<message-emoji />
|
<message-emoji class="header-icon" />
|
||||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
<span class="header-label">消息</span>
|
||||||
unreadMessageCount
|
<span v-if="unreadMessageCount > 0" class="unread-badge">{{ unreadMessageCount }}</span>
|
||||||
}}</span>
|
|
||||||
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
@@ -192,6 +208,7 @@ const copyInviteLink = async () => {
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
toast.error('请先登录')
|
toast.error('请先登录')
|
||||||
|
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -333,7 +350,7 @@ onMounted(async () => {
|
|||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
backdrop-filter: var(--blur-10);
|
backdrop-filter: var(--blur-10);
|
||||||
color: var(--header-text-color);
|
color: var(--primary-color);
|
||||||
border-bottom: 1px solid var(--header-border-color);
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +393,7 @@ onMounted(async () => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
padding-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.micon {
|
.micon {
|
||||||
@@ -464,16 +482,14 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite_text {
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.invite_text:hover {
|
.invite_text:hover {
|
||||||
|
opacity: 0.8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite_text,
|
||||||
|
.online-count,
|
||||||
.rss-icon,
|
.rss-icon,
|
||||||
.new-post-icon,
|
.new-post-icon,
|
||||||
.messages-icon {
|
.messages-icon {
|
||||||
@@ -484,8 +500,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.unread-badge {
|
.unread-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -4px;
|
||||||
right: -10px;
|
right: -6px;
|
||||||
background-color: #ff4d4f;
|
background-color: #ff4d4f;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -500,8 +516,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.unread-dot {
|
.unread-dot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -2px;
|
top: 0;
|
||||||
right: -4px;
|
right: -1px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -513,14 +529,58 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.online-count {
|
.online-count {
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 统一图标按钮风格 === */
|
||||||
|
.header-icon-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: color 0.25s ease, transform 0.15s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon-item:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 点击时瞬间高亮 + 轻微缩放 */
|
||||||
|
.header-icon-item:active {
|
||||||
|
color: var(--primary-color-hover);
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-label {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 在线人数的数字文字样式(无背景) */
|
||||||
|
.header-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -6px;
|
||||||
|
color: var(--primary-color); /* 🔹 使用主题主色 */
|
||||||
|
background: none; /* 🔹 去掉背景 */
|
||||||
|
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
||||||
|
font-weight: 600; /* 加一点权重让数字更醒目 */
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0; /* 去掉内边距 */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes rss-glow {
|
@keyframes rss-glow {
|
||||||
0% {
|
0% {
|
||||||
text-shadow: 0 0 0px var(--primary-color);
|
text-shadow: 0 0 0px var(--primary-color);
|
||||||
@@ -556,5 +616,12 @@ onMounted(async () => {
|
|||||||
.header-content-right {
|
.header-content-right {
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
/* 手机不显示文字 */
|
||||||
|
.header-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.header-badge {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,21 +35,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-container">
|
|
||||||
<div
|
|
||||||
v-if="props.contentType !== 'message'"
|
|
||||||
class="make-reaction-item like-reaction"
|
|
||||||
@click="toggleReaction('LIKE')"
|
|
||||||
>
|
|
||||||
<like v-if="!userReacted('LIKE')" />
|
|
||||||
<like v-else theme="filled" />
|
|
||||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
|
||||||
</div>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-if="panelVisible"
|
v-if="panelVisible"
|
||||||
class="reactions-panel"
|
class="reactions-panel"
|
||||||
|
ref="reactionsPanelRef"
|
||||||
|
:style="panelInlineStyle"
|
||||||
@mouseenter="cancelHide"
|
@mouseenter="cancelHide"
|
||||||
@mouseleave="scheduleHide"
|
@mouseleave="scheduleHide"
|
||||||
>
|
>
|
||||||
@@ -69,7 +59,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
@@ -102,8 +92,6 @@ const counts = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
|
||||||
|
|
||||||
const userReacted = (type) =>
|
const userReacted = (type) =>
|
||||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
@@ -152,9 +140,11 @@ const displayedReactions = computed(() => {
|
|||||||
.map((type) => ({ type }))
|
.map((type) => ({ type }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
|
const panelTypes = computed(() => sortedReactionTypes.value)
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
const panelVisible = ref(false)
|
||||||
|
const reactionsPanelRef = ref(null)
|
||||||
|
const panelInlineStyle = ref({})
|
||||||
let hideTimer = null
|
let hideTimer = null
|
||||||
const openPanel = () => {
|
const openPanel = () => {
|
||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
@@ -170,6 +160,33 @@ const cancelHide = () => {
|
|||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePanelInlineStyle = () => {
|
||||||
|
if (!panelVisible.value) return
|
||||||
|
const panelEl = reactionsPanelRef.value
|
||||||
|
if (!panelEl) return
|
||||||
|
const parentEl = panelEl.closest('.reactions-container')?.parentElement
|
||||||
|
if (!parentEl) return
|
||||||
|
const parentWidth = parentEl.clientWidth - 20
|
||||||
|
panelInlineStyle.value = {
|
||||||
|
width: 'max-content',
|
||||||
|
maxWidth: `${parentWidth}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(panelVisible, async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
await nextTick()
|
||||||
|
updatePanelInlineStyle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(panelTypes, async () => {
|
||||||
|
if (panelVisible.value) {
|
||||||
|
await nextTick()
|
||||||
|
updatePanelInlineStyle()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const toggleReaction = async (type) => {
|
const toggleReaction = async (type) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -245,6 +262,15 @@ const toggleReaction = async (type) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initialize()
|
await initialize()
|
||||||
|
window.addEventListener('resize', updatePanelInlineStyle)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleReaction,
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -253,11 +279,7 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.reactions-viewer {
|
||||||
@@ -295,32 +317,6 @@ onMounted(async () => {
|
|||||||
padding-left: 5px;
|
padding-left: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.make-reaction-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-item {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.like-reaction {
|
|
||||||
color: #ff0000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-item:hover {
|
|
||||||
background-color: #ffe2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-count {
|
.reactions-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -328,7 +324,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.reactions-panel {
|
.reactions-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 50px;
|
bottom: 40px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.result-body {
|
.result-body {
|
||||||
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,8 +75,8 @@
|
|||||||
<star v-if="!article.rssExcluded" class="featured-icon" />
|
<star v-if="!article.rssExcluded" class="featured-icon" />
|
||||||
{{ article.title }}
|
{{ article.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="article-item-description main-item">
|
<NuxtLink class="article-item-description main-item" :to="`/posts/${article.id}`">
|
||||||
{{ sanitizeDescription(article.description) }}
|
<div v-html="sanitizeDescription(article.description)"></div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="article-info-container main-item">
|
<div class="article-info-container main-item">
|
||||||
<ArticleCategory :category="article.category" />
|
<ArticleCategory :category="article.category" />
|
||||||
@@ -378,8 +378,27 @@ onBeforeUnmount(() => {
|
|||||||
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
/** 供 InfiniteLoadMore 重建用的 key:筛选/Tab 改变即重建内部状态 */
|
||||||
const ioKey = computed(() => asyncKey.value.join('::'))
|
const ioKey = computed(() => asyncKey.value.join('::'))
|
||||||
|
|
||||||
/** 其他工具函数 **/
|
// 在首页摘要加载贴吧表情包
|
||||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
const sanitizeDescription = (text) => {
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
// 1️⃣ 先把 Markdown 转成纯文本
|
||||||
|
const plain = stripMarkdown(text)
|
||||||
|
|
||||||
|
// 2️⃣ 替换 :tieba123: 为 <img>
|
||||||
|
const withEmoji = plain.replace(/:tieba(\d+):/g, (match, num) => {
|
||||||
|
const key = `tieba${num}`
|
||||||
|
const file = tiebaEmoji[key]
|
||||||
|
return file
|
||||||
|
? `<img loading="lazy" class="emoji" src="${file}" alt="${key}">`
|
||||||
|
: match // 没有匹配到图片则保留原样
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3️⃣ 可选:截断纯文本长度(防止撑太长)
|
||||||
|
const truncated = withEmoji.length > 500 ? withEmoji.slice(0, 500) + '…' : withEmoji
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
}
|
||||||
|
|
||||||
// 页面选项同步到全局状态
|
// 页面选项同步到全局状态
|
||||||
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
||||||
@@ -537,16 +556,22 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
|
|||||||
.article-comments,
|
.article-comments,
|
||||||
.header-item.comments {
|
.header-item.comments {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-views,
|
.article-views,
|
||||||
.header-item.views {
|
.header-item.views {
|
||||||
width: 5%;
|
width: 5%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-time,
|
.article-time,
|
||||||
.header-item.activity {
|
.header-item.activity {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-item-title {
|
.article-item-title {
|
||||||
|
|||||||
@@ -61,14 +61,31 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<ReactionsGroup
|
<div class="message-reaction-row">
|
||||||
:model-value="item.reactions"
|
<ReactionsGroup
|
||||||
content-type="message"
|
:ref="(el) => setMessageReactionRef(item.id, el)"
|
||||||
:content-id="item.id"
|
:model-value="item.reactions"
|
||||||
@update:modelValue="(v) => (item.reactions = v)"
|
content-type="message"
|
||||||
>
|
:content-id="item.id"
|
||||||
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
|
@update:modelValue="(v) => (item.reactions = v)"
|
||||||
</ReactionsGroup>
|
/>
|
||||||
|
<div class="message-reaction-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: isMessageLiked(item) }"
|
||||||
|
@click="toggleMessageLike(item)"
|
||||||
|
>
|
||||||
|
<like v-if="!isMessageLiked(item)" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="getMessageLikeCount(item)" class="reaction-count">{{
|
||||||
|
getMessageLikeCount(item)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div @click="setReply(item)" class="reaction-action reply-btn">
|
||||||
|
<next /> 写个回复...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
<div class="empty-container">
|
<div class="empty-container">
|
||||||
@@ -180,6 +197,32 @@ function setReply(message) {
|
|||||||
replyTo.value = message
|
replyTo.value = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageReactionRefs = new Map()
|
||||||
|
function setMessageReactionRef(id, el) {
|
||||||
|
if (el) {
|
||||||
|
messageReactionRefs.set(id, el)
|
||||||
|
} else {
|
||||||
|
messageReactionRefs.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageLikeCount(message) {
|
||||||
|
return (message.reactions || []).filter((reaction) => reaction.type === 'LIKE').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageLiked(message) {
|
||||||
|
const username = currentUser.value?.username
|
||||||
|
if (!username) return false
|
||||||
|
return (message.reactions || []).some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMessageLike(message) {
|
||||||
|
const group = messageReactionRefs.get(message.id)
|
||||||
|
group?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
|
|
||||||
/** 改造:滚动函数 —— smooth & instant */
|
/** 改造:滚动函数 —— smooth & instant */
|
||||||
function scrollToBottomSmooth() {
|
function scrollToBottomSmooth() {
|
||||||
const el = messagesListEl.value
|
const el = messagesListEl.value
|
||||||
@@ -710,6 +753,55 @@ function goBack() {
|
|||||||
background-color: var(--normal-light-background-color);
|
background-color: var(--normal-light-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-reaction-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 16px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
.reply-header {
|
.reply-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -723,14 +815,8 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-btn {
|
.reply-btn {
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
color: var(--primary-color);
|
||||||
|
|
||||||
.reply-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-reply {
|
.active-reply {
|
||||||
|
|||||||
@@ -92,11 +92,26 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
<ReactionsGroup
|
||||||
<div class="make-reaction-item copy-link" @click="copyPostLink">
|
ref="postReactionsGroupRef"
|
||||||
|
v-model="postReactions"
|
||||||
|
content-type="post"
|
||||||
|
:content-id="postId"
|
||||||
|
/>
|
||||||
|
<div class="article-footer-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: postLikedByMe }"
|
||||||
|
@click="togglePostLike"
|
||||||
|
>
|
||||||
|
<like v-if="!postLikedByMe" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-action copy-link" @click="copyPostLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,6 +238,18 @@ const postContent = ref('')
|
|||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
const postReactions = ref([])
|
const postReactions = ref([])
|
||||||
|
const postReactionsGroupRef = ref(null)
|
||||||
|
const postLikeCount = computed(
|
||||||
|
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
|
||||||
|
)
|
||||||
|
const postLikedByMe = computed(() =>
|
||||||
|
postReactions.value.some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const togglePostLike = () => {
|
||||||
|
postReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const changeLogs = ref([])
|
const changeLogs = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
@@ -366,7 +393,11 @@ const changeLogIcon = (l) => {
|
|||||||
return 'unlock'
|
return 'unlock'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'PINNED') {
|
} else if (l.type === 'PINNED') {
|
||||||
return 'pin-icon'
|
if (l.newPinnedAt) {
|
||||||
|
return 'pin'
|
||||||
|
} else {
|
||||||
|
return 'clear-icon'
|
||||||
|
}
|
||||||
} else if (l.type === 'FEATURED') {
|
} else if (l.type === 'FEATURED') {
|
||||||
if (l.newFeatured) {
|
if (l.newFeatured) {
|
||||||
return 'star'
|
return 'star'
|
||||||
@@ -1241,35 +1272,53 @@ onMounted(async () => {
|
|||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.article-footer-actions {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-viewer-item-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-viewer-item {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-container {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-link:hover {
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.like-action {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.selected {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--normal-light-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-count {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.copy-link:hover {
|
||||||
background-color: #e2e2e2;
|
background-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,18 @@
|
|||||||
<div class="reason-description">
|
<div class="reason-description">
|
||||||
为了我们社区的良性发展,请填写注册理由,我们将根据你的理由审核你的注册, 谢谢!
|
为了我们社区的良性发展,请填写注册理由,我们将根据你的理由审核你的注册, 谢谢!
|
||||||
</div>
|
</div>
|
||||||
<div class="reason-input-container">
|
<div class="input-wrapper">
|
||||||
<BaseInput textarea rows="4" v-model="reason" placeholder="20个字以上"></BaseInput>
|
<div class="reason-input-container">
|
||||||
<div class="char-count">{{ reason.length }}/20</div>
|
<BaseInput
|
||||||
|
textarea
|
||||||
|
rows="4"
|
||||||
|
v-model="reason"
|
||||||
|
placeholder="请输入至少20个字符"
|
||||||
|
></BaseInput>
|
||||||
|
<div class="char-count">{{ reason.length }}/20</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
|
||||||
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">
|
<div v-if="!isWaitingForRegister" class="signup-page-button-primary" @click="submit">
|
||||||
提交
|
提交
|
||||||
</div>
|
</div>
|
||||||
@@ -38,8 +45,9 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!reason.value || reason.value.trim().length < 20) {
|
const trimmedReason = reason.value.trim()
|
||||||
error.value = '请至少输入20个字'
|
if (!trimmedReason || trimmedReason.length < 20) {
|
||||||
|
error.value = '请至少输入20个字符'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,16 +106,29 @@ const submit = async () => {
|
|||||||
width: 400px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-input-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.char-count {
|
.char-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #888;
|
color: #888;
|
||||||
width: 100%;
|
background-color: transparent;
|
||||||
text-align: right;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
color: red;
|
color: red;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.signup-page-button-primary {
|
.signup-page-button-primary {
|
||||||
|
|||||||
@@ -849,7 +849,8 @@ watch(selectedTab, async (val) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
gap: 20px;
|
row-gap: 40px; /* 行间距 */
|
||||||
|
column-gap: 20px; /* 列间距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-title {
|
.summary-title {
|
||||||
@@ -888,10 +889,10 @@ watch(selectedTab, async (val) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-divider {
|
.summary-divider {
|
||||||
margin-top: 20px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 20px;
|
row-gap: 40px; /* 行间距 */
|
||||||
|
column-gap: 20px; /* 列间距 */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
ApplicationMenu,
|
ApplicationMenu,
|
||||||
Search,
|
Search,
|
||||||
Copy,
|
Copy,
|
||||||
|
CopyLink,
|
||||||
Loading,
|
Loading,
|
||||||
Rss,
|
Rss,
|
||||||
MessageEmoji,
|
MessageEmoji,
|
||||||
@@ -111,6 +112,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
nuxtApp.vueApp.component('ApplicationMenu', ApplicationMenu)
|
nuxtApp.vueApp.component('ApplicationMenu', ApplicationMenu)
|
||||||
nuxtApp.vueApp.component('SearchIcon', Search)
|
nuxtApp.vueApp.component('SearchIcon', Search)
|
||||||
nuxtApp.vueApp.component('Copy', Copy)
|
nuxtApp.vueApp.component('Copy', Copy)
|
||||||
|
nuxtApp.vueApp.component('CopyLink', CopyLink)
|
||||||
nuxtApp.vueApp.component('Loading', Loading)
|
nuxtApp.vueApp.component('Loading', Loading)
|
||||||
nuxtApp.vueApp.component('Rss', Rss)
|
nuxtApp.vueApp.component('Rss', Rss)
|
||||||
nuxtApp.vueApp.component('MessageEmoji', MessageEmoji)
|
nuxtApp.vueApp.component('MessageEmoji', MessageEmoji)
|
||||||
|
|||||||
@@ -93,9 +93,8 @@ function getCircle(event) {
|
|||||||
|
|
||||||
function withViewTransition(event, applyFn, direction = true) {
|
function withViewTransition(event, applyFn, direction = true) {
|
||||||
if (typeof document !== 'undefined' && document.startViewTransition) {
|
if (typeof document !== 'undefined' && document.startViewTransition) {
|
||||||
const transition = document.startViewTransition(async () => {
|
const transition = document.startViewTransition(() => {
|
||||||
applyFn()
|
applyFn()
|
||||||
await nextTick()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
transition.ready
|
transition.ready
|
||||||
@@ -111,6 +110,7 @@ function withViewTransition(event, applyFn, direction = true) {
|
|||||||
{
|
{
|
||||||
duration: 400,
|
duration: 400,
|
||||||
easing: 'ease-in-out',
|
easing: 'ease-in-out',
|
||||||
|
fill: 'both',
|
||||||
pseudoElement: direction
|
pseudoElement: direction
|
||||||
? '::view-transition-new(root)'
|
? '::view-transition-new(root)'
|
||||||
: '::view-transition-old(root)',
|
: '::view-transition-old(root)',
|
||||||
|
|||||||
@@ -18,51 +18,6 @@ server {
|
|||||||
add_header X-Upstream $upstream_addr always;
|
add_header X-Upstream $upstream_addr always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /api/ws {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
# 升级所需
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
# 统一透传这些头(你在 /api/ 有,/api/ws 也要有)
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2) SockJS(包含 /info、/iframe.html、/.../websocket 等)
|
|
||||||
location ^~ /api/sockjs {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection $connection_upgrade;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
|
|
||||||
# 如要同源 iframe 回退,下面两行二选一(或者交给 Spring Security 的 sameOrigin)
|
|
||||||
# proxy_hide_header X-Frame-Options;
|
|
||||||
# add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8080/api/;
|
proxy_pass http://127.0.0.1:8080/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -148,7 +103,7 @@ server {
|
|||||||
|
|
||||||
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
# ---------- WEBSOCKET GATEWAY TO :8082 ----------
|
||||||
location ^~ /websocket/ {
|
location ^~ /websocket/ {
|
||||||
proxy_pass http://127.0.0.1:8082/;
|
proxy_pass http://127.0.0.1:8084/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -167,6 +122,7 @@ server {
|
|||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name open-isle.com www.open-isle.com;
|
server_name open-isle.com www.open-isle.com;
|
||||||
|
|||||||
Reference in New Issue
Block a user