Compare commits

...

120 Commits

Author SHA1 Message Date
Tim
0033374481 Allow iframe embeds in markdown sanitizer 2025-10-02 21:42:52 +08:00
tim
8f36422609 fix: 解决传参 2025-10-02 15:28:25 +08:00
tim
b98871bed9 fix: mysql 时区 2025-10-01 16:40:23 +08:00
tim
2cb8c12f65 fix: 修改main 2025-10-01 16:33:14 +08:00
Tim
87a256ba0c Merge pull request #1037 from nagisa77/feature/docker
所有业务适配Docker
2025-10-01 16:19:10 +08:00
tim
737157e557 fix: add timezone 2025-10-01 16:08:53 +08:00
tim
6f9570dc95 fix: 时区限制 2025-10-01 15:38:12 +08:00
tim
12bc405856 fix: 时区限制 2025-10-01 15:36:18 +08:00
tim
a2b0cd1a47 fix: 新增deploy 2025-10-01 11:36:55 +08:00
tim
25a7f1e138 fix: add deploy 2025-10-01 11:35:02 +08:00
tim
a6dd2bfbc2 Revert "fix: 修改文件名"
This reverts commit a0ea63700f.
2025-09-30 21:40:14 +08:00
tim
a0ea63700f fix: 修改文件名 2025-09-30 21:34:30 +08:00
tim
b49e20d010 fix: 添加环境名、变量名 2025-09-30 21:33:14 +08:00
tim
e44443a605 Merge remote-tracking branch 'origin/main' into feature/docker 2025-09-30 20:12:43 +08:00
Tim
0a3bfb9451 Merge pull request #1041 from nagisa77/codex/add-points-module-and-history-tracking
feat: add admin point grants and history UI
2025-09-30 20:12:31 +08:00
Tim
adfc05b9b2 feat: add admin point grants and history UI 2025-09-30 20:11:45 +08:00
tim
18a6953ff7 fix: 解决opensearch问题 2025-09-30 15:15:11 +08:00
tim
181ac7bc8f fix: 容器port修改 2025-09-30 15:09:59 +08:00
tim
9dc9ca9bd8 Revert "fix: 端口适配"
This reverts commit 180c45bf2d.
2025-09-30 15:02:40 +08:00
tim
2457efd11d Revert "fix: healthy check 修改"
This reverts commit b62b9c691f.
2025-09-30 15:02:36 +08:00
tim
b62b9c691f fix: healthy check 修改 2025-09-30 14:54:57 +08:00
tim
180c45bf2d fix: 端口适配 2025-09-30 14:52:27 +08:00
tim
263f2deeb1 fix: 修改yaml 2025-09-30 14:32:30 +08:00
tim
22b813e40b fix: 修改yaml 2025-09-30 14:16:10 +08:00
tim
d00dbbbd03 fix: 修改前端生产构建方案 2025-09-30 13:51:43 +08:00
Tim
3b92bdaf2a Merge pull request #1038 from smallclover/main
修改按钮样式
2025-09-30 10:46:07 +08:00
tim
7ce5de7f7c fix: 自部署基本完善 2025-09-30 10:45:31 +08:00
tim
28618c7452 fix: springboot healthy检测完成 2025-09-30 10:22:39 +08:00
tim
f8a2ee6ee9 fix: use server port 2025-09-30 01:45:47 +08:00
tim
ca26b931da fix: use server port 2025-09-30 01:19:13 +08:00
tim
24fe90cfc6 fix: change port 2025-09-30 00:47:18 +08:00
tim
5971700e8a fix: 新增依赖 2025-09-30 00:33:05 +08:00
smallclover
f872a32410 修改按钮样式
1. 文字变为白色
2. 按钮样式和其他按钮统一
2025-09-29 21:47:12 +09:00
Tim
fffd335ebb fix: 两个springboot新增探活机制 2025-09-29 19:54:37 +08:00
Tim
287d52df10 feat: healthy.检测 2025-09-29 19:47:55 +08:00
Tim
73790d1992 feat: healthy.检测 2025-09-29 19:42:54 +08:00
Tim
3d5cee6e68 feat: mysql 乱码处理 2025-09-29 19:41:04 +08:00
Tim
2f509cc2d8 feat: mysql 自定义初始化 2025-09-29 19:27:24 +08:00
Tim
35c503eb6c feat: mysql 自定义初始化 2025-09-29 19:26:02 +08:00
Tim
0cf8113691 Revert "feat: 移动文件位置"
This reverts commit b2a29913aa.
2025-09-29 19:14:48 +08:00
Tim
b2a29913aa feat: 移动文件位置 2025-09-29 19:11:18 +08:00
Tim
2b6d7c5ab9 fix: 新增多种url供开发者选择 2025-09-29 18:12:55 +08:00
Tim
e9878487e8 fix: 容器内流量转发 2025-09-29 18:08:35 +08:00
Tim
201af061e4 fix: 简单修改 2025-09-29 17:55:19 +08:00
Tim
4080f60f60 fix: rabbitmq 初始化 2025-09-29 16:46:25 +08:00
Tim
06d76438e8 fix: 前端初步调通 2025-09-29 16:04:14 +08:00
Tim
bb955c98ba fix: 后台实现链接各个服务 2025-09-29 15:16:32 +08:00
Tim
a12368602d fix: 尝试docker部署 2025-09-29 10:52:59 +08:00
Tim
208c875868 fix: 去除compose中重复声明 2025-09-29 10:42:17 +08:00
Tim
39ae8c02cb fix: 修改.env.example 2025-09-29 10:29:37 +08:00
tim
0119605649 feat: 先把每日定时构件给注释掉 2025-09-29 01:14:50 +08:00
Tim
0d7dc93a67 fix: 初步转移为docker 2025-09-28 21:06:52 +08:00
Tim
774611f3a8 Merge pull request #1033 from nagisa77/feature/open_search
Feature: Open Search
2025-09-28 19:19:21 +08:00
Tim
04616a30f3 fix: 新增端口指定 2025-09-28 19:13:17 +08:00
Tim
c0ca615439 feat: 新增docker部署相关信息 2025-09-28 18:05:49 +08:00
Tim
b0597d34b6 fix: 去除无用代码 2025-09-28 17:58:58 +08:00
Tim
e3f680ad0f fix: 索引/查询规则微调 2025-09-28 17:58:10 +08:00
Tim
c8a1e6d8c8 fix: 禁用首字母匹配 2025-09-28 15:21:02 +08:00
Tim
ffebeb46b7 fix: 新增拼音 2025-09-28 15:08:20 +08:00
Tim
2977d2898f fix: 后端highlight 2025-09-28 14:55:56 +08:00
Tim
8869121bcb fix: add pinyin 2025-09-28 14:28:45 +08:00
Tim
61f6e7c90a Merge pull request #1034 from smallclover/main
UI调整
2025-09-28 10:06:28 +08:00
smallclover
892aa6a7c6 UI调整
https://github.com/nagisa77/OpenIsle/issues/855
2025-09-27 08:59:11 +09:00
tim
23cc2d1606 feat: 新增贴文reindex 2025-09-26 18:19:29 +08:00
tim
44addd2a7b fix: 搜索main路径跑通 2025-09-26 18:03:25 +08:00
tim
0bc65077df feat: opensearch init 2025-09-26 16:37:13 +08:00
tim
69869348f6 Revert "feat: add open search support"
This reverts commit 4821b77c17.
2025-09-26 15:36:31 +08:00
Tim
4821b77c17 feat: add open search support 2025-09-26 15:34:06 +08:00
Tim
4fc7c861ee Merge pull request #1030 from nagisa77/codex/add-op/-identifier-in-posts
feat: add OP badge to post comments
2025-09-25 13:37:28 +08:00
Tim
81dfddf6e1 feat: highlight post author in comments 2025-09-25 13:34:25 +08:00
Tim
8b93aa95cf Merge pull request #1027 from nagisa77/feature/avatar_count
fix: 移动端头像显示问题 #1023
2025-09-24 16:58:29 +08:00
tim
425fc7d2b1 fix: 移动端头像显示问题 #1023 2025-09-24 16:57:42 +08:00
Tim
0fff73b682 Merge pull request #1025 from nagisa77/codex/add-pagination-support-for-tags-qfn36n
feat: paginate tags across backend and ui
2025-09-24 16:18:09 +08:00
Tim
e1171212d7 Merge pull request #1026 from nagisa77/codex/fix-dropdown-to-scroll-after-loading-more
fix: keep dropdown at bottom after loading more
2025-09-24 16:15:14 +08:00
Tim
e96db5d0d6 fix: keep dropdown at bottom after loading more 2025-09-24 16:14:45 +08:00
tim
1083c4241a fix: 修复语法问题 2025-09-24 16:06:17 +08:00
Tim
1eeabab41a feat: paginate tags across backend and ui 2025-09-24 15:58:24 +08:00
Tim
2b5f6f2208 Merge pull request #1022 from nagisa77/feature/user_list_and_avatar
Feature/user list and avatar
2025-09-24 01:51:51 +08:00
tim
bda377336d fix: 优化一些头像属性 2025-09-24 01:51:02 +08:00
tim
77507f7b18 Revert "style: enhance BaseUserAvatar presentation"
This reverts commit 229439aa05.
2025-09-24 01:38:41 +08:00
Tim
a39f2f7c00 Merge pull request #1021 from nagisa77/codex/improve-baseuseravatar-styling-ok22do
style: enhance BaseUserAvatar presentation
2025-09-24 01:31:46 +08:00
Tim
229439aa05 style: enhance BaseUserAvatar presentation 2025-09-24 01:31:31 +08:00
tim
612881f1b1 Revert "refine BaseUserAvatar styling"
This reverts commit c68c5985f6.
2025-09-24 01:31:05 +08:00
Tim
05c7bc18d7 Merge pull request #1020 from nagisa77/codex/improve-baseuseravatar-styling-z7f617
Enhance BaseUserAvatar aesthetics
2025-09-24 01:23:25 +08:00
Tim
c68c5985f6 refine BaseUserAvatar styling 2025-09-24 01:23:12 +08:00
tim
7d44791011 Revert "feat: refresh base user avatar styling"
This reverts commit 4b8229b0a1.
2025-09-24 01:22:50 +08:00
Tim
15b992b949 Merge pull request #1019 from nagisa77/codex/improve-baseuseravatar-styling
feat: refresh base user avatar styling
2025-09-24 01:21:29 +08:00
Tim
4b8229b0a1 feat: refresh base user avatar styling 2025-09-24 01:21:12 +08:00
tim
6e4fbc3c42 fix: base avatar 重构 2025-09-24 00:43:57 +08:00
Tim
779264623c Merge pull request #1018 from nagisa77/codex/create-baseuseravatar-component-zv8hyo
feat: add base user avatar component
2025-09-24 00:31:11 +08:00
Tim
76aef40de7 feat: add base user avatar component 2025-09-24 00:30:54 +08:00
tim
a1eccb3b1e Revert "feat: add BaseUserAvatar and unify avatar usage"
This reverts commit efbb83924b.
2025-09-24 00:30:23 +08:00
Tim
0f75a95dbe Merge pull request #1017 from nagisa77/codex/create-baseuseravatar-component
feat: unify avatar rendering with BaseUserAvatar
2025-09-24 00:27:10 +08:00
Tim
efbb83924b feat: add BaseUserAvatar and unify avatar usage 2025-09-24 00:26:51 +08:00
tim
26d1db79f4 fix: user list 结构调整 2025-09-23 23:59:42 +08:00
Tim
dc13b2941f Merge pull request #1016 from nagisa77/feature/vditor_layout
fix: 移动端--频道--表情无法显示完全 #994
2025-09-23 23:48:59 +08:00
tim
13c250d392 fix: 移动端--频道--表情无法显示完全 #994 2025-09-23 23:48:31 +08:00
tim
f5b40feaa2 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-23 23:32:07 +08:00
tim
c47c318e6f fix: 简单更新本地调试端口 2025-09-23 23:31:53 +08:00
Tim
c02d993e90 Merge pull request #1015 from nagisa77/feature/api_click
fix: 修复api playgrond 跳转问题
2025-09-23 23:27:33 +08:00
tim
f36bcb74ca fix: 修复api playgrond 跳转问题 2025-09-23 23:27:01 +08:00
Tim
2263fd97db Merge pull request #1014 from nagisa77/feature/loading_spin
fix: 修复整个按钮都在转的问题
2025-09-23 23:18:08 +08:00
tim
9234d1099e fix: 修复整个按钮都在转的问题 2025-09-23 23:14:32 +08:00
Tim
373dece19d Merge pull request #1012 from nagisa77/feature/loading_icon
fix: loading icon
2025-09-19 23:21:48 +08:00
tim
b09828bcc2 fix: loading icon 2025-09-19 23:20:50 +08:00
Tim
8751a7707c Merge pull request #1010 from nagisa77/codex/update-overview-page-to-display-api-paths
feat(docs): show API routes on overview
2025-09-19 18:03:18 +08:00
Tim
f91b240802 feat(docs): show API routes on overview 2025-09-19 17:57:44 +08:00
Tim
062b289f7a Revert "fix: 新增openAPI配置选项"
This reverts commit c1dc77f6db.
2025-09-19 17:47:41 +08:00
Tim
c1dc77f6db fix: 新增openAPI配置选项 2025-09-19 16:46:28 +08:00
Tim
cea60175c2 fix: basetimeline 去除hover属性 2025-09-19 16:39:10 +08:00
Tim
2bd3630512 Merge pull request #1008 from nagisa77/feature/user_page_timeline
user page timeline
2025-09-19 16:22:20 +08:00
tim
a9d8181940 fix: timeline ui 重构 2025-09-19 16:21:19 +08:00
Tim
4cc108094d Merge pull request #1009 from nagisa77/codex/integrate-timelinetagitem-and-refactor-components
feat: extract timeline tag item component
2025-09-19 13:50:15 +08:00
Tim
bfa57cce44 feat: extract timeline tag item component 2025-09-19 13:44:37 +08:00
tim
8ebdcd94f5 fix: timeline 继承标签介绍 2025-09-19 11:30:58 +08:00
tim
9991210db2 fix: 部分ui修改 2025-09-19 11:21:27 +08:00
Tim
1c59815afa Merge pull request #1007 from nagisa77/codex/refactor-user-posts-display-components-aopsvr
Enhance user timeline post metadata and grouping
2025-09-19 00:32:17 +08:00
Tim
e7593c8ebf Enhance user timeline post metadata and grouping 2025-09-19 00:31:52 +08:00
tim
bc767a6ac9 Revert "Enhance user timeline grouping and post metadata"
This reverts commit b6c2471bc3.
2025-09-19 00:31:24 +08:00
Tim
1c1915285d Merge pull request #1006 from nagisa77/codex/refactor-user-posts-display-components
Enhance user timeline grouping and post metadata
2025-09-19 00:22:58 +08:00
104 changed files with 3863 additions and 742 deletions

105
.env.example Normal file
View File

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

View File

@@ -2,8 +2,8 @@ name: CI & CD
on:
workflow_dispatch:
schedule:
- cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
# schedule:
# - cron: "0 19 * * *" # 每天 UTC 19:00相当于北京时间凌晨3点
jobs:
build-and-deploy:

View File

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

View File

@@ -132,6 +132,23 @@
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 高阶 Java 客户端 -->
<dependency>
<groupId>org.opensearch.client</groupId>
<artifactId>opensearch-java</artifactId>
<version>3.2.0</version>
</dependency>
<!-- 低阶 RestClient提供 org.opensearch.client.RestClient 给你的 RestClientTransport 用 -->
<dependency>
<groupId>org.opensearch.client</groupId>
<artifactId>opensearch-rest-client</artifactId>
<version>3.2.0</version>
</dependency>
</dependencies>
<build>

View File

@@ -97,12 +97,14 @@ public class SecurityConfig {
"http://localhost:8081",
"http://localhost:8082",
"http://localhost:3000",
"http://frontend_dev:3000",
"http://frontend_service:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
"http://192.168.7.90",
"http://192.168.7.90:3000",
"https://petstore.swagger.io",
// 允许自建OpenAPI地址
"https://docs.open-isle.com",
@@ -177,6 +179,8 @@ public class SecurityConfig {
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods")
.permitAll()
.requestMatchers("/actuator/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/tags/**")
@@ -230,6 +234,7 @@ public class SecurityConfig {
uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") ||
uri.startsWith("/api/medals") ||
uri.startsWith("/actuator") ||
uri.startsWith("/api/rss"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {

View File

@@ -0,0 +1,35 @@
package com.openisle.controller;
import com.openisle.dto.AdminGrantPointRequest;
import com.openisle.service.PointService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/admin/points")
@RequiredArgsConstructor
public class AdminPointController {
private final PointService pointService;
@PostMapping("/grant")
@SecurityRequirement(name = "JWT")
@Operation(summary = "Grant points", description = "Grant points to a user as administrator")
@ApiResponse(responseCode = "200", description = "Points granted")
public Map<String, Object> grant(
@RequestBody AdminGrantPointRequest request,
Authentication auth
) {
String username = request.getUsername();
int balance = pointService.grantPointByAdmin(auth.getName(), username, request.getAmount());
return Map.of("username", username.trim(), "point", balance);
}
}

View File

@@ -115,6 +115,9 @@ public class SearchController {
dto.setSubText(r.subText());
dto.setExtra(r.extra());
dto.setPostId(r.postId());
dto.setHighlightedText(r.highlightedText());
dto.setHighlightedSubText(r.highlightedSubText());
dto.setHighlightedExtra(r.highlightedExtra());
return dto;
})
.collect(Collectors.toList());

View File

@@ -100,18 +100,32 @@ public class TagController {
)
public List<TagDto> list(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "limit", required = false) Integer limit
) {
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
if (postCntByTagIds == null) {
postCntByTagIds = java.util.Collections.emptyMap();
}
Map<Long, Long> finalPostCntByTagIds = postCntByTagIds;
List<TagDto> dtos = tags
.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.map(t -> tagMapper.toDto(t, finalPostCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (page != null && pageSize != null && page >= 0 && pageSize > 0) {
int fromIndex = page * pageSize;
if (fromIndex >= dtos.size()) {
return java.util.Collections.emptyList();
}
int toIndex = Math.min(fromIndex + pageSize, dtos.size());
return new java.util.ArrayList<>(dtos.subList(fromIndex, toIndex));
}
if (limit != null && limit > 0 && dtos.size() > limit) {
return dtos.subList(0, limit);
return new java.util.ArrayList<>(dtos.subList(0, limit));
}
return dtos;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class AdminGrantPointRequest {
private String username;
private int amount;
}

View File

@@ -12,4 +12,7 @@ public class SearchResultDto {
private String subText;
private String extra;
private Long postId;
private String highlightedText;
private String highlightedSubText;
private String highlightedExtra;
}

View File

@@ -24,8 +24,8 @@ public class UserMapper {
private final PostReadService postReadService;
private final LevelService levelService;
private final MedalService medalService;
private final TagMapper tagMapper;
private final CategoryMapper categoryMapper;
private final TagMapper tagMapper;
@Value("${app.snippet-length}")
private int snippetLength;
@@ -93,11 +93,8 @@ public class UserMapper {
dto.setCreatedAt(post.getCreatedAt());
dto.setCategory(categoryMapper.toDto(post.getCategory()));
dto.setTags(post.getTags().stream().map(tagMapper::toDto).collect(Collectors.toList()));
if (post.getLastReplyAt() == null) {
commentService.updatePostCommentStats(post);
}
dto.setCommentCount(post.getCommentCount());
dto.setViews(post.getViews());
dto.setCommentCount(post.getCommentCount());
return dto;
}

View File

@@ -13,4 +13,5 @@ public enum PointHistoryType {
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD,
ADMIN_GRANT,
}

View File

@@ -0,0 +1,14 @@
package com.openisle.search;
public class NoopSearchIndexer implements SearchIndexer {
@Override
public void indexDocument(String index, SearchDocument document) {
// no-op
}
@Override
public void deleteDocument(String index, Long id) {
// no-op
}
}

View File

@@ -0,0 +1,78 @@
package com.openisle.search;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.core5.http.HttpHost;
import org.opensearch.client.RestClient;
import org.opensearch.client.RestClientBuilder;
import org.opensearch.client.json.jackson.JacksonJsonpMapper;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.transport.rest_client.RestClientTransport;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
@Configuration
@EnableConfigurationProperties(OpenSearchProperties.class)
public class OpenSearchConfig {
@Bean(destroyMethod = "close")
@ConditionalOnProperty(prefix = "app.search", name = "enabled", havingValue = "true")
public RestClient openSearchRestClient(OpenSearchProperties properties) {
RestClientBuilder builder = RestClient.builder(
new HttpHost(properties.getScheme(), properties.getHost(), properties.getPort())
);
if (StringUtils.hasText(properties.getUsername())) {
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
new AuthScope(properties.getHost(), properties.getPort()),
new UsernamePasswordCredentials(
properties.getUsername(),
properties.getPassword() != null ? properties.getPassword().toCharArray() : new char[0]
)
);
builder.setHttpClientConfigCallback(httpClientBuilder ->
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider)
);
}
return builder.build();
}
@Bean(destroyMethod = "close")
@ConditionalOnBean(RestClient.class)
public RestClientTransport openSearchTransport(RestClient restClient) {
return new RestClientTransport(restClient, new JacksonJsonpMapper());
}
@Bean
@ConditionalOnBean(RestClientTransport.class)
public OpenSearchClient openSearchClient(RestClientTransport transport) {
return new OpenSearchClient(transport);
}
@Bean
@ConditionalOnBean(OpenSearchClient.class)
public SearchIndexInitializer searchIndexInitializer(
OpenSearchClient client,
OpenSearchProperties properties
) {
return new SearchIndexInitializer(client, properties);
}
@Bean
@ConditionalOnBean(OpenSearchClient.class)
public SearchIndexer openSearchIndexer(OpenSearchClient client, OpenSearchProperties properties) {
return new OpenSearchIndexer(client);
}
@Bean
@ConditionalOnMissingBean(SearchIndexer.class)
public SearchIndexer noopSearchIndexer() {
return new NoopSearchIndexer();
}
}

View File

@@ -0,0 +1,49 @@
package com.openisle.search;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch.core.DeleteRequest;
import org.opensearch.client.opensearch.core.IndexRequest;
import org.opensearch.client.opensearch.core.IndexResponse;
@Slf4j
@RequiredArgsConstructor
public class OpenSearchIndexer implements SearchIndexer {
private final OpenSearchClient client;
@Override
public void indexDocument(String index, SearchDocument document) {
if (document == null || document.entityId() == null) {
return;
}
try {
IndexRequest<SearchDocument> request = IndexRequest.of(builder ->
builder.index(index).id(document.entityId().toString()).document(document)
);
IndexResponse response = client.index(request);
log.info(
"Indexed document {} into {} with result {}",
document.entityId(),
index,
response.result()
);
} catch (IOException e) {
log.warn("Failed to index document {} into {}", document.entityId(), index, e);
}
}
@Override
public void deleteDocument(String index, Long id) {
if (id == null) {
return;
}
try {
client.delete(DeleteRequest.of(builder -> builder.index(index).id(id.toString())));
} catch (IOException e) {
log.warn("Failed to delete document {} from {}", id, index, e);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.openisle.search;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
@Getter
@Setter
@ConfigurationProperties(prefix = "app.search")
public class OpenSearchProperties {
private boolean enabled = false;
private String host = "localhost";
private int port = 9200;
private String scheme = "http";
private String username;
private String password;
private String indexPrefix = "openisle";
private boolean initialize = true;
private int highlightFragmentSize = 200;
private boolean reindexOnStartup = false;
private int reindexBatchSize = 500;
private Indices indices = new Indices();
public String postsIndex() {
return indexName(indices.posts);
}
public String commentsIndex() {
return indexName(indices.comments);
}
public String usersIndex() {
return indexName(indices.users);
}
public String categoriesIndex() {
return indexName(indices.categories);
}
public String tagsIndex() {
return indexName(indices.tags);
}
private String indexName(String suffix) {
if (indexPrefix == null || indexPrefix.isBlank()) {
return suffix;
}
return indexPrefix + "-" + suffix;
}
@Getter
@Setter
public static class Indices {
private String posts = "posts";
private String comments = "comments";
private String users = "users";
private String categories = "categories";
private String tags = "tags";
}
}

View File

@@ -0,0 +1,15 @@
package com.openisle.search;
import java.util.List;
public record SearchDocument(
String type,
Long entityId,
String title,
String content,
String author,
String category,
List<String> tags,
Long postId,
Long createdAt
) {}

View File

@@ -0,0 +1,127 @@
package com.openisle.search;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.Tag;
import com.openisle.model.User;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public final class SearchDocumentFactory {
private SearchDocumentFactory() {}
public static SearchDocument fromPost(Post post) {
if (post == null || post.getId() == null) {
return null;
}
List<String> tags = post.getTags() == null
? Collections.emptyList()
: post
.getTags()
.stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new SearchDocument(
"post",
post.getId(),
post.getTitle(),
post.getContent(),
post.getAuthor() != null ? post.getAuthor().getUsername() : null,
post.getCategory() != null ? post.getCategory().getName() : null,
tags,
post.getId(),
toEpochMillis(post.getCreatedAt())
);
}
public static SearchDocument fromComment(Comment comment) {
if (comment == null || comment.getId() == null) {
return null;
}
Post post = comment.getPost();
List<String> tags = post == null || post.getTags() == null
? Collections.emptyList()
: post
.getTags()
.stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.collect(Collectors.toList());
return new SearchDocument(
"comment",
comment.getId(),
post != null ? post.getTitle() : null,
comment.getContent(),
comment.getAuthor() != null ? comment.getAuthor().getUsername() : null,
post != null && post.getCategory() != null ? post.getCategory().getName() : null,
tags,
post != null ? post.getId() : null,
toEpochMillis(comment.getCreatedAt())
);
}
public static SearchDocument fromUser(User user) {
if (user == null || user.getId() == null) {
return null;
}
return new SearchDocument(
"user",
user.getId(),
user.getUsername(),
user.getIntroduction(),
null,
null,
Collections.emptyList(),
null,
toEpochMillis(user.getCreatedAt())
);
}
public static SearchDocument fromCategory(Category category) {
if (category == null || category.getId() == null) {
return null;
}
return new SearchDocument(
"category",
category.getId(),
category.getName(),
category.getDescription(),
null,
null,
Collections.emptyList(),
null,
null
);
}
public static SearchDocument fromTag(Tag tag) {
if (tag == null || tag.getId() == null) {
return null;
}
return new SearchDocument(
"tag",
tag.getId(),
tag.getName(),
tag.getDescription(),
null,
null,
Collections.emptyList(),
null,
toEpochMillis(tag.getCreatedAt())
);
}
private static Long toEpochMillis(LocalDateTime time) {
if (time == null) {
return null;
}
return time.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
}

View File

@@ -0,0 +1,33 @@
package com.openisle.search;
import com.openisle.search.event.DeleteDocumentEvent;
import com.openisle.search.event.IndexDocumentEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Slf4j
@Component
@RequiredArgsConstructor
public class SearchIndexEventListener {
private final SearchIndexer searchIndexer;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleIndex(IndexDocumentEvent event) {
if (event == null || event.document() == null) {
return;
}
searchIndexer.indexDocument(event.index(), event.document());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void handleDelete(DeleteDocumentEvent event) {
if (event == null) {
return;
}
searchIndexer.deleteDocument(event.index(), event.id());
}
}

View File

@@ -0,0 +1,99 @@
package com.openisle.search;
import com.openisle.model.Category;
import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.search.event.DeleteDocumentEvent;
import com.openisle.search.event.IndexDocumentEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class SearchIndexEventPublisher {
private final ApplicationEventPublisher publisher;
private final OpenSearchProperties properties;
public void publishPostSaved(Post post) {
if (!properties.isEnabled() || post == null || post.getStatus() != PostStatus.PUBLISHED) {
return;
}
SearchDocument document = SearchDocumentFactory.fromPost(post);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.postsIndex(), document));
}
}
public void publishPostDeleted(Long postId) {
if (!properties.isEnabled() || postId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.postsIndex(), postId));
}
public void publishCommentSaved(Comment comment) {
if (!properties.isEnabled() || comment == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromComment(comment);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.commentsIndex(), document));
}
}
public void publishCommentDeleted(Long commentId) {
if (!properties.isEnabled() || commentId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.commentsIndex(), commentId));
}
public void publishUserSaved(User user) {
if (!properties.isEnabled() || user == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromUser(user);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.usersIndex(), document));
}
}
public void publishCategorySaved(Category category) {
if (!properties.isEnabled() || category == null) {
return;
}
SearchDocument document = SearchDocumentFactory.fromCategory(category);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.categoriesIndex(), document));
}
}
public void publishCategoryDeleted(Long categoryId) {
if (!properties.isEnabled() || categoryId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.categoriesIndex(), categoryId));
}
public void publishTagSaved(Tag tag) {
if (!properties.isEnabled() || tag == null || !tag.isApproved()) {
return;
}
SearchDocument document = SearchDocumentFactory.fromTag(tag);
if (document != null) {
publisher.publishEvent(new IndexDocumentEvent(properties.tagsIndex(), document));
}
}
public void publishTagDeleted(Long tagId) {
if (!properties.isEnabled() || tagId == null) {
return;
}
publisher.publishEvent(new DeleteDocumentEvent(properties.tagsIndex(), tagId));
}
}

View File

@@ -0,0 +1,219 @@
package com.openisle.search;
import jakarta.annotation.PostConstruct;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.json.JsonData;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.mapping.Property;
import org.opensearch.client.opensearch._types.mapping.TypeMapping;
import org.opensearch.client.opensearch.indices.IndexSettings;
@Slf4j
@RequiredArgsConstructor
public class SearchIndexInitializer {
private final OpenSearchClient client;
private final OpenSearchProperties properties;
@PostConstruct
public void initialize() {
if (!properties.isEnabled() || !properties.isInitialize()) {
return;
}
ensureIndex(properties.postsIndex(), this::postMapping);
ensureIndex(properties.commentsIndex(), this::commentMapping);
ensureIndex(properties.usersIndex(), this::userMapping);
ensureIndex(properties.categoriesIndex(), this::categoryMapping);
ensureIndex(properties.tagsIndex(), this::tagMapping);
}
private void ensureIndex(String index, java.util.function.Supplier<TypeMapping> mappingSupplier) {
try {
boolean exists = client
.indices()
.exists(builder -> builder.index(index))
.value();
if (exists) {
return;
}
client
.indices()
.create(builder ->
builder.index(index).settings(this::applyPinyinAnalysis).mappings(mappingSupplier.get())
);
log.info("Created OpenSearch index {}", index);
} catch (IOException e) {
log.warn("Failed to initialize OpenSearch index {}", index, e);
}
}
private TypeMapping postMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyinOnly()) // content 不做 .raw避免超长 keyword
.properties("author", keywordWithRawAndPinyin())
.properties("category", keywordWithRawAndPinyin())
.properties("tags", keywordWithRawAndPinyin())
.properties("postId", Property.of(p -> p.long_(l -> l)))
.properties(
"createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
)
);
}
private TypeMapping commentMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyinOnly())
.properties("author", keywordWithRawAndPinyin())
.properties("category", keywordWithRawAndPinyin())
.properties("tags", keywordWithRawAndPinyin())
.properties("postId", Property.of(p -> p.long_(l -> l)))
.properties(
"createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
)
);
}
private TypeMapping userMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyinOnly())
.properties(
"createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
)
);
}
private TypeMapping categoryMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyinOnly())
);
}
private TypeMapping tagMapping() {
return TypeMapping.of(builder ->
builder
.properties("type", Property.of(p -> p.keyword(k -> k)))
.properties("title", textWithRawAndPinyin())
.properties("content", textWithPinyinOnly())
.properties(
"createdAt",
Property.of(p -> p.date(d -> d.format("strict_date_optional_time||epoch_millis")))
)
);
}
/** 文本字段:.rawkeyword 精确) + .py拼音短语精确 + .zhICU+2~3gram 召回) */
private Property textWithRawAndPinyin() {
return Property.of(p ->
p.text(t ->
t
.fields("raw", f -> f.keyword(k -> k.normalizer("lowercase_normalizer")))
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
)
);
}
/** 长文本 content保留拼音 + 新增 zh 子字段(不加 .raw避免过长 keyword */
private Property textWithPinyinOnly() {
return Property.of(p ->
p.text(t ->
t
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
)
);
}
/** 关键词字段author/category/tagskeyword 等值 + .py + .zh尽量对齐标题策略 */
private Property keywordWithRawAndPinyin() {
return Property.of(p ->
p.keyword(k ->
k
.normalizer("lowercase_normalizer")
.fields("raw", f -> f.keyword(kk -> kk.normalizer("lowercase_normalizer")))
.fields("py", f -> f.text(sub -> sub.analyzer("py_index").searchAnalyzer("py_search")))
.fields("zh", f ->
f.text(sub -> sub.analyzer("zh_ngram_index").searchAnalyzer("zh_search"))
)
)
);
}
/** 新增 zh 分析器ICU + 2~3gram并保留你已有的 pinyin/normalizer 设置 */
private IndexSettings.Builder applyPinyinAnalysis(IndexSettings.Builder builder) {
Map<String, JsonData> settings = new LinkedHashMap<>();
// --- 已有keyword normalizer用于 .raw
settings.put("analysis.normalizer.lowercase_normalizer.type", JsonData.of("custom"));
settings.put(
"analysis.normalizer.lowercase_normalizer.filter",
JsonData.of(List.of("lowercase"))
);
// --- 已有pinyin filter + analyzers
settings.put("analysis.filter.py_filter.type", JsonData.of("pinyin"));
settings.put("analysis.filter.py_filter.keep_full_pinyin", JsonData.of(true));
settings.put("analysis.filter.py_filter.keep_joined_full_pinyin", JsonData.of(true));
settings.put("analysis.filter.py_filter.keep_first_letter", JsonData.of(false));
settings.put("analysis.filter.py_filter.remove_duplicated_term", JsonData.of(true));
settings.put("analysis.analyzer.py_index.type", JsonData.of("custom"));
settings.put("analysis.analyzer.py_index.tokenizer", JsonData.of("standard"));
settings.put(
"analysis.analyzer.py_index.filter",
JsonData.of(List.of("lowercase", "py_filter"))
);
settings.put("analysis.analyzer.py_search.type", JsonData.of("custom"));
settings.put("analysis.analyzer.py_search.tokenizer", JsonData.of("standard"));
settings.put(
"analysis.analyzer.py_search.filter",
JsonData.of(List.of("lowercase", "py_filter"))
);
settings.put("analysis.filter.zh_ngram_2_3.type", JsonData.of("ngram"));
settings.put("analysis.filter.zh_ngram_2_3.min_gram", JsonData.of(2));
settings.put("analysis.filter.zh_ngram_2_3.max_gram", JsonData.of(3));
settings.put("analysis.analyzer.zh_ngram_index.type", JsonData.of("custom"));
settings.put("analysis.analyzer.zh_ngram_index.tokenizer", JsonData.of("icu_tokenizer"));
settings.put(
"analysis.analyzer.zh_ngram_index.filter",
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
);
settings.put("analysis.analyzer.zh_search.type", JsonData.of("custom"));
settings.put("analysis.analyzer.zh_search.tokenizer", JsonData.of("icu_tokenizer"));
settings.put(
"analysis.analyzer.zh_search.filter",
JsonData.of(List.of("lowercase", "zh_ngram_2_3"))
);
settings.forEach(builder::customSettings);
return builder;
}
}

View File

@@ -0,0 +1,6 @@
package com.openisle.search;
public interface SearchIndexer {
void indexDocument(String index, SearchDocument document);
void deleteDocument(String index, Long id);
}

View File

@@ -0,0 +1,30 @@
package com.openisle.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class SearchReindexInitializer implements CommandLineRunner {
private final OpenSearchProperties properties;
private final SearchReindexService searchReindexService;
@Override
public void run(String... args) {
if (!properties.isEnabled()) {
log.info("Search indexing disabled, skipping startup reindex.");
return;
}
if (!properties.isReindexOnStartup()) {
log.debug("Startup reindex disabled by configuration.");
return;
}
searchReindexService.reindexAll();
}
}

View File

@@ -0,0 +1,94 @@
package com.openisle.search;
import com.openisle.model.Post;
import com.openisle.model.PostStatus;
import com.openisle.model.Tag;
import com.openisle.repository.CategoryRepository;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import java.util.Objects;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class SearchReindexService {
private final SearchIndexer searchIndexer;
private final OpenSearchProperties properties;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
@Transactional(readOnly = true)
public void reindexAll() {
if (!properties.isEnabled()) {
log.info("Search indexing is disabled, skipping reindex operation.");
return;
}
log.info("Starting full search reindex operation.");
reindex(properties.postsIndex(), postRepository::findAll, (Post post) ->
post.getStatus() == PostStatus.PUBLISHED ? SearchDocumentFactory.fromPost(post) : null
);
reindex(
properties.commentsIndex(),
commentRepository::findAll,
SearchDocumentFactory::fromComment
);
reindex(properties.usersIndex(), userRepository::findAll, SearchDocumentFactory::fromUser);
reindex(
properties.categoriesIndex(),
categoryRepository::findAll,
SearchDocumentFactory::fromCategory
);
reindex(properties.tagsIndex(), tagRepository::findAll, (Tag tag) ->
tag.isApproved() ? SearchDocumentFactory.fromTag(tag) : null
);
log.info("Completed full search reindex operation.");
}
private <T> void reindex(
String index,
Function<Pageable, Page<T>> pageSupplier,
Function<T, SearchDocument> mapper
) {
int batchSize = Math.max(1, properties.getReindexBatchSize());
int pageNumber = 0;
Page<T> page;
do {
Pageable pageable = PageRequest.of(pageNumber, batchSize);
page = pageSupplier.apply(pageable);
if (page.isEmpty() && pageNumber == 0) {
log.info("No entities found for index {}.", index);
}
log.info("Reindexing {} entities for index {}.", page.getTotalElements(), index);
for (T entity : page) {
SearchDocument document = mapper.apply(entity);
if (Objects.nonNull(document)) {
searchIndexer.indexDocument(index, document);
}
}
pageNumber++;
} while (page.hasNext());
}
}

View File

@@ -0,0 +1,3 @@
package com.openisle.search.event;
public record DeleteDocumentEvent(String index, Long id) {}

View File

@@ -0,0 +1,5 @@
package com.openisle.search.event;
import com.openisle.search.SearchDocument;
public record IndexDocumentEvent(String index, SearchDocument document) {}

View File

@@ -3,6 +3,7 @@ package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.Category;
import com.openisle.repository.CategoryRepository;
import com.openisle.search.SearchIndexEventPublisher;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
public class CategoryService {
private final CategoryRepository categoryRepository;
private final SearchIndexEventPublisher searchIndexEventPublisher;
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public Category createCategory(String name, String description, String icon, String smallIcon) {
@@ -22,7 +24,9 @@ public class CategoryService {
category.setDescription(description);
category.setIcon(icon);
category.setSmallIcon(smallIcon);
return categoryRepository.save(category);
Category saved = categoryRepository.save(category);
searchIndexEventPublisher.publishCategorySaved(saved);
return saved;
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
@@ -48,12 +52,15 @@ public class CategoryService {
if (smallIcon != null) {
category.setSmallIcon(smallIcon);
}
return categoryRepository.save(category);
Category saved = categoryRepository.save(category);
searchIndexEventPublisher.publishCategorySaved(saved);
return saved;
}
@CacheEvict(value = CachingConfig.CATEGORY_CACHE_NAME, allEntries = true)
public void deleteCategory(Long id) {
categoryRepository.deleteById(id);
searchIndexEventPublisher.publishCategoryDeleted(id);
}
public Category getCategory(Long id) {

View File

@@ -16,6 +16,7 @@ import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.NotificationService;
import com.openisle.service.PointService;
import com.openisle.service.SubscriptionService;
@@ -49,6 +50,7 @@ public class CommentService {
private final PointHistoryRepository pointHistoryRepository;
private final PointService pointService;
private final ImageUploader imageUploader;
private final SearchIndexEventPublisher searchIndexEventPublisher;
@CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true)
@Transactional
@@ -124,6 +126,7 @@ public class CommentService {
}
notificationService.notifyMentions(content, author, post, comment);
log.debug("addComment finished for comment {}", comment.getId());
searchIndexEventPublisher.publishCommentSaved(comment);
return comment;
}
@@ -221,6 +224,7 @@ public class CommentService {
}
notificationService.notifyMentions(content, author, parent.getPost(), comment);
log.debug("addReply finished for comment {}", comment.getId());
searchIndexEventPublisher.publishCommentSaved(comment);
return comment;
}
@@ -360,7 +364,9 @@ public class CommentService {
// 逻辑删除评论
Post post = comment.getPost();
Long commentId = comment.getId();
commentRepository.delete(comment);
searchIndexEventPublisher.publishCommentDeleted(commentId);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);

View File

@@ -43,6 +43,22 @@ public class PointService {
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
public int grantPointByAdmin(String adminName, String targetUsername, int amount) {
if (amount <= 0) {
throw new FieldException("amount", "积分必须为正数");
}
if (targetUsername == null || targetUsername.isBlank()) {
throw new FieldException("username", "用户名不能为空");
}
String normalizedUsername = targetUsername.trim();
User admin = userRepository.findByUsername(adminName).orElseThrow();
User target = userRepository
.findByUsername(normalizedUsername)
.orElseThrow(() -> new FieldException("username", "用户不存在"));
addPoint(target, amount, PointHistoryType.ADMIN_GRANT, null, null, admin);
return target.getPoint();
}
public void processLotteryJoin(User participant, LotteryPost post) {
int cost = post.getPointCost();
if (cost > 0) {

View File

@@ -16,6 +16,7 @@ import com.openisle.repository.PostSubscriptionRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.EmailSender;
import java.time.Duration;
import java.time.LocalDateTime;
@@ -73,6 +74,8 @@ public class PostService {
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations =
new ConcurrentHashMap<>();
private final SearchIndexEventPublisher searchIndexEventPublisher;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -103,7 +106,8 @@ public class PostService {
PostChangeLogService postChangeLogService,
PointHistoryRepository pointHistoryRepository,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode,
RedisTemplate redisTemplate
RedisTemplate redisTemplate,
SearchIndexEventPublisher searchIndexEventPublisher
) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -130,6 +134,7 @@ public class PostService {
this.publishMode = publishMode;
this.redisTemplate = redisTemplate;
this.searchIndexEventPublisher = searchIndexEventPublisher;
}
@EventListener(ApplicationReadyEvent.class)
@@ -346,6 +351,9 @@ public class PostService {
);
scheduledFinalizations.put(pp.getId(), future);
}
if (post.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(post);
}
return post;
}
@@ -868,10 +876,12 @@ public class PostService {
if (!tag.isApproved()) {
tag.setApproved(true);
tagRepository.save(tag);
searchIndexEventPublisher.publishTagSaved(tag);
}
}
post.setStatus(PostStatus.PUBLISHED);
post = postRepository.save(post);
searchIndexEventPublisher.publishPostSaved(post);
notificationService.createNotification(
post.getAuthor(),
NotificationType.POST_REVIEWED,
@@ -895,13 +905,16 @@ public class PostService {
if (!tag.isApproved()) {
long count = postRepository.countDistinctByTags_Id(tag.getId());
if (count <= 1) {
Long tagId = tag.getId();
post.getTags().remove(tag);
tagRepository.delete(tag);
searchIndexEventPublisher.publishTagDeleted(tagId);
}
}
}
post.setStatus(PostStatus.REJECTED);
post = postRepository.save(post);
searchIndexEventPublisher.publishPostDeleted(post.getId());
notificationService.createNotification(
post.getAuthor(),
NotificationType.POST_REVIEWED,
@@ -1042,6 +1055,9 @@ public class PostService {
if (!oldTags.equals(newTags)) {
postChangeLogService.recordTagChange(updated, user, oldTags, newTags);
}
if (updated.getStatus() == PostStatus.PUBLISHED) {
searchIndexEventPublisher.publishPostSaved(updated);
}
return updated;
}
@@ -1094,8 +1110,10 @@ public class PostService {
}
}
String title = post.getTitle();
Long postId = post.getId();
postChangeLogService.deleteLogsForPost(post);
postRepository.delete(post);
searchIndexEventPublisher.publishPostDeleted(postId);
if (adminDeleting) {
notificationService.createNotification(
author,

View File

@@ -11,14 +11,30 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.OpenSearchProperties;
import com.openisle.search.SearchDocument;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.opensearch.client.opensearch.OpenSearchClient;
import org.opensearch.client.opensearch._types.FieldValue;
import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
import org.opensearch.client.opensearch.core.SearchResponse;
import org.opensearch.client.opensearch.core.search.Hit;
import org.springframework.stereotype.Service;
import org.springframework.web.util.HtmlUtils;
@Service
@Slf4j
@RequiredArgsConstructor
public class SearchService {
@@ -27,10 +43,14 @@ public class SearchService {
private final CommentRepository commentRepository;
private final CategoryRepository categoryRepository;
private final TagRepository tagRepository;
private final Optional<OpenSearchClient> openSearchClient;
private final OpenSearchProperties openSearchProperties;
@org.springframework.beans.factory.annotation.Value("${app.snippet-length}")
private int snippetLength;
private static final int DEFAULT_OPEN_SEARCH_LIMIT = 50;
public List<User> searchUsers(String keyword) {
return userRepository.findByUsernameContainingIgnoreCase(keyword);
}
@@ -64,49 +84,113 @@ public class SearchService {
}
public List<SearchResult> globalSearch(String keyword) {
if (keyword == null || keyword.isBlank()) {
return List.of();
}
if (isOpenSearchEnabled()) {
try {
List<SearchResult> results = searchWithOpenSearch(keyword);
if (!results.isEmpty()) {
return results;
}
} catch (IOException e) {
log.warn("OpenSearch global search failed, falling back to database query", e);
}
}
return fallbackGlobalSearch(keyword);
}
private List<SearchResult> fallbackGlobalSearch(String keyword) {
final String effectiveKeyword = keyword == null ? "" : keyword.trim();
Stream<SearchResult> users = searchUsers(keyword)
.stream()
.map(u ->
new SearchResult("user", u.getId(), u.getUsername(), u.getIntroduction(), null, null)
new SearchResult(
"user",
u.getId(),
u.getUsername(),
u.getIntroduction(),
null,
null,
highlightHtml(u.getUsername(), effectiveKeyword),
highlightHtml(u.getIntroduction(), effectiveKeyword),
null
)
);
Stream<SearchResult> categories = searchCategories(keyword)
.stream()
.map(c ->
new SearchResult("category", c.getId(), c.getName(), null, c.getDescription(), null)
new SearchResult(
"category",
c.getId(),
c.getName(),
null,
c.getDescription(),
null,
highlightHtml(c.getName(), effectiveKeyword),
null,
highlightHtml(c.getDescription(), effectiveKeyword)
)
);
Stream<SearchResult> tags = searchTags(keyword)
.stream()
.map(t -> new SearchResult("tag", t.getId(), t.getName(), null, t.getDescription(), null));
.map(t ->
new SearchResult(
"tag",
t.getId(),
t.getName(),
null,
t.getDescription(),
null,
highlightHtml(t.getName(), effectiveKeyword),
null,
highlightHtml(t.getDescription(), effectiveKeyword)
)
);
// Merge post results while removing duplicates between search by content
// and search by title
List<SearchResult> mergedPosts = Stream.concat(
searchPosts(keyword)
.stream()
.map(p ->
new SearchResult(
.map(p -> {
String snippet = extractSnippet(p.getContent(), keyword, false);
return new SearchResult(
"post",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, false),
null
)
),
snippet,
null,
highlightHtml(p.getTitle(), effectiveKeyword),
highlightHtml(
p.getCategory() != null ? p.getCategory().getName() : null,
effectiveKeyword
),
highlightHtml(snippet, effectiveKeyword)
);
}),
searchPostsByTitle(keyword)
.stream()
.map(p ->
new SearchResult(
.map(p -> {
String snippet = extractSnippet(p.getContent(), keyword, true);
return new SearchResult(
"post_title",
p.getId(),
p.getTitle(),
p.getCategory() != null ? p.getCategory().getName() : null,
extractSnippet(p.getContent(), keyword, true),
null
)
)
snippet,
null,
highlightHtml(p.getTitle(), effectiveKeyword),
highlightHtml(
p.getCategory() != null ? p.getCategory().getName() : null,
effectiveKeyword
),
highlightHtml(snippet, effectiveKeyword)
);
})
)
.collect(
java.util.stream.Collectors.toMap(
@@ -122,22 +206,366 @@ public class SearchService {
Stream<SearchResult> comments = searchComments(keyword)
.stream()
.map(c ->
new SearchResult(
.map(c -> {
String snippet = extractSnippet(c.getContent(), keyword, false);
return new SearchResult(
"comment",
c.getId(),
c.getPost().getTitle(),
c.getAuthor().getUsername(),
extractSnippet(c.getContent(), keyword, false),
c.getPost().getId()
)
);
snippet,
c.getPost().getId(),
highlightHtml(c.getPost().getTitle(), effectiveKeyword),
highlightHtml(c.getAuthor().getUsername(), effectiveKeyword),
highlightHtml(snippet, effectiveKeyword)
);
});
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
.flatMap(s -> s)
.toList();
}
private boolean isOpenSearchEnabled() {
return openSearchProperties.isEnabled() && openSearchClient.isPresent();
}
// 在类里加上(字段或静态常量都可)
private static final java.util.regex.Pattern HANS_PATTERN = java.util.regex.Pattern.compile(
"\\p{IsHan}"
);
private static boolean containsHan(String s) {
return s != null && HANS_PATTERN.matcher(s).find();
}
private List<SearchResult> searchWithOpenSearch(String keyword) throws IOException {
var client = openSearchClient.orElse(null);
if (client == null) return List.of();
final String qRaw = keyword == null ? "" : keyword.trim();
if (qRaw.isEmpty()) return List.of();
final boolean hasHan = containsHan(qRaw);
SearchResponse<SearchDocument> resp = client.search(
b ->
b
.index(searchIndices())
.trackTotalHits(t -> t.enabled(true))
.query(qb ->
qb.bool(bool -> {
// ---------- 严格层 ----------
// 中文/任意短语(轻微符号/空白扰动)
bool.should(s ->
s.matchPhrase(mp -> mp.field("title").query(qRaw).slop(2).boost(6.0f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("content").query(qRaw).slop(2).boost(2.5f))
);
// 结构化等值(.raw
bool.should(s ->
s.term(t ->
t
.field("author.raw")
.value(v -> v.stringValue(qRaw))
.boost(4.0f)
)
);
bool.should(s ->
s.term(t ->
t
.field("category.raw")
.value(v -> v.stringValue(qRaw))
.boost(3.0f)
)
);
bool.should(s ->
s.term(t ->
t
.field("tags.raw")
.value(v -> v.stringValue(qRaw))
.boost(3.0f)
)
);
// 拼音短语(严格)
bool.should(s ->
s.matchPhrase(mp -> mp.field("title.py").query(qRaw).slop(1).boost(4.0f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("content.py").query(qRaw).slop(1).boost(1.8f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("author.py").query(qRaw).slop(1).boost(2.2f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("category.py").query(qRaw).slop(1).boost(2.0f))
);
bool.should(s ->
s.matchPhrase(mp -> mp.field("tags.py").query(qRaw).slop(1).boost(2.0f))
);
// ---------- 放宽层(仅当包含中文时启用) ----------
if (hasHan) {
// title.zh
bool.should(s ->
s.match(m ->
m
.field("title.zh")
.query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
.operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
.boost(3.0f)
)
);
// content.zh
bool.should(s ->
s.match(m ->
m
.field("content.zh")
.query(org.opensearch.client.opensearch._types.FieldValue.of(qRaw))
.operator(org.opensearch.client.opensearch._types.query_dsl.Operator.Or)
.minimumShouldMatch("2<-1 3<-1 4<-1 5<-2 6<-2 7<-3")
.boost(1.6f)
)
);
}
return bool.minimumShouldMatch("1");
})
)
// ---------- 高亮:允许跨子字段回填 + 匹配字段组 ----------
.highlight(h -> {
var hb = h
.preTags("<mark>")
.postTags("</mark>")
.requireFieldMatch(false)
.fields("title", f ->
f
.fragmentSize(highlightFragmentSize())
.numberOfFragments(1)
.matchedFields(List.of("title", "title.zh", "title.py"))
)
.fields("content", f ->
f
.fragmentSize(highlightFragmentSize())
.numberOfFragments(1)
.matchedFields(List.of("content", "content.zh", "content.py"))
)
.fields("title.zh", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("content.zh", f ->
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
)
.fields("title.py", f -> f.fragmentSize(highlightFragmentSize()).numberOfFragments(1))
.fields("content.py", f ->
f.fragmentSize(highlightFragmentSize()).numberOfFragments(1)
)
.fields("author", f -> f.numberOfFragments(0))
.fields("author.py", f -> f.numberOfFragments(0))
.fields("category", f -> f.numberOfFragments(0))
.fields("category.py", f -> f.numberOfFragments(0))
.fields("tags", f -> f.numberOfFragments(0))
.fields("tags.py", f -> f.numberOfFragments(0));
return hb;
})
.size(DEFAULT_OPEN_SEARCH_LIMIT > 0 ? DEFAULT_OPEN_SEARCH_LIMIT : 10),
SearchDocument.class
);
return mapHits(resp.hits().hits(), qRaw);
}
/** Lucene query_string 安全转义(保留 * 由我们自己追加) */
private static String escapeForQueryString(String s) {
if (s == null || s.isEmpty()) return "";
StringBuilder sb = new StringBuilder(s.length() * 2);
for (char c : s.toCharArray()) {
switch (c) {
case '+':
case '-':
case '=':
case '&':
case '|':
case '>':
case '<':
case '!':
case '(':
case ')':
case '{':
case '}':
case '[':
case ']':
case '^':
case '"':
case '~': /* case '*': */ /* case '?': */
case ':':
case '\\':
case '/':
sb.append('\\').append(c);
break;
default:
sb.append(c);
}
}
return sb.toString();
}
private int highlightFragmentSize() {
int configured = openSearchProperties.getHighlightFragmentSize();
if (configured > 0) {
return configured;
}
if (snippetLength > 0) {
return snippetLength;
}
return 200;
}
private List<String> searchIndices() {
return List.of(
openSearchProperties.postsIndex(),
openSearchProperties.commentsIndex(),
openSearchProperties.usersIndex(),
openSearchProperties.categoriesIndex(),
openSearchProperties.tagsIndex()
);
}
private List<SearchResult> mapHits(List<Hit<SearchDocument>> hits, String keyword) {
List<SearchResult> results = new ArrayList<>();
for (Hit<SearchDocument> hit : hits) {
SearchResult result = mapHit(hit, keyword);
if (result != null) {
results.add(result);
}
}
return results;
}
private SearchResult mapHit(Hit<SearchDocument> hit, String keyword) {
SearchDocument document = hit.source();
if (document == null || document.entityId() == null) {
return null;
}
Map<String, List<String>> highlight = hit.highlight();
String highlightedContent = firstHighlight(
highlight,
"content",
"content.py",
"content.zh",
"content.raw"
);
String highlightedTitle = firstHighlight(
highlight,
"title",
"title.py",
"title.zh",
"title.raw"
);
String highlightedAuthor = firstHighlight(highlight, "author", "author.py");
String highlightedCategory = firstHighlight(highlight, "category", "category.py");
boolean highlightTitle = highlightedTitle != null && !highlightedTitle.isBlank();
String documentType = document.type() != null ? document.type() : "";
String effectiveType = documentType;
if ("post".equals(documentType) && highlightTitle) {
effectiveType = "post_title";
}
String snippetHtml = highlightedContent != null && !highlightedContent.isBlank()
? highlightedContent
: null;
if (snippetHtml == null && highlightTitle) {
snippetHtml = highlightedTitle;
}
String snippet = snippetHtml != null && !snippetHtml.isBlank()
? cleanHighlight(snippetHtml)
: null;
boolean fromStart = "post_title".equals(effectiveType);
if (snippet == null || snippet.isBlank()) {
snippet = fallbackSnippet(document.content(), keyword, fromStart);
if (snippetHtml == null) {
snippetHtml = highlightHtml(snippet, keyword);
}
} else if (snippetHtml == null) {
snippetHtml = highlightHtml(snippet, keyword);
}
if (snippet == null) {
snippet = "";
}
String subText = null;
Long postId = null;
if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
subText = document.category();
} else if ("comment".equals(documentType)) {
subText = document.author();
postId = document.postId();
}
String highlightedText = highlightTitle
? highlightedTitle
: highlightHtml(document.title(), keyword);
String highlightedSubText;
if ("comment".equals(documentType)) {
highlightedSubText = highlightedAuthor != null && !highlightedAuthor.isBlank()
? highlightedAuthor
: highlightHtml(subText, keyword);
} else if ("post".equals(documentType) || "post_title".equals(effectiveType)) {
highlightedSubText = highlightedCategory != null && !highlightedCategory.isBlank()
? highlightedCategory
: highlightHtml(subText, keyword);
} else {
highlightedSubText = highlightHtml(subText, keyword);
}
String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword);
return new SearchResult(
effectiveType,
document.entityId(),
document.title(),
subText,
snippet,
postId,
highlightedText,
highlightedSubText,
highlightedExtra
);
}
private String firstHighlight(Map<String, List<String>> highlight, String... fields) {
if (highlight == null || fields == null) {
return null;
}
for (String field : fields) {
if (field == null) {
continue;
}
List<String> values = highlight.get(field);
if (values == null || values.isEmpty()) {
continue;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
}
return null;
}
private String cleanHighlight(String value) {
if (value == null) {
return null;
}
return value.replaceAll("<[^>]+>", "");
}
private String fallbackSnippet(String content, String keyword, boolean fromStart) {
if (content == null) {
return "";
}
return extractSnippet(content, keyword, fromStart);
}
private String extractSnippet(String content, String keyword, boolean fromStart) {
if (content == null) return "";
int limit = snippetLength;
@@ -165,12 +593,45 @@ public class SearchService {
return snippet;
}
private String highlightHtml(String text, String keyword) {
if (text == null) {
return null;
}
String normalizedKeyword = keyword == null ? "" : keyword.trim();
if (normalizedKeyword.isEmpty()) {
return HtmlUtils.htmlEscape(text);
}
Pattern pattern = Pattern.compile(
Pattern.quote(normalizedKeyword),
Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE
);
Matcher matcher = pattern.matcher(text);
if (!matcher.find()) {
return HtmlUtils.htmlEscape(text);
}
matcher.reset();
StringBuilder sb = new StringBuilder();
int lastEnd = 0;
while (matcher.find()) {
sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd, matcher.start())));
sb.append("<mark>");
sb.append(HtmlUtils.htmlEscape(matcher.group()));
sb.append("</mark>");
lastEnd = matcher.end();
}
sb.append(HtmlUtils.htmlEscape(text.substring(lastEnd)));
return sb.toString();
}
public record SearchResult(
String type,
Long id,
String text,
String subText,
String extra,
Long postId
Long postId,
String highlightedText,
String highlightedSubText,
String highlightedExtra
) {}
}

View File

@@ -5,6 +5,7 @@ import com.openisle.model.Tag;
import com.openisle.model.User;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
@@ -20,6 +21,7 @@ public class TagService {
private final TagRepository tagRepository;
private final TagValidator tagValidator;
private final UserRepository userRepository;
private final SearchIndexEventPublisher searchIndexEventPublisher;
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public Tag createTag(
@@ -43,7 +45,9 @@ public class TagService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
tag.setCreator(creator);
}
return tagRepository.save(tag);
Tag saved = tagRepository.save(tag);
searchIndexEventPublisher.publishTagSaved(saved);
return saved;
}
public Tag createTag(
@@ -78,12 +82,15 @@ public class TagService {
if (smallIcon != null) {
tag.setSmallIcon(smallIcon);
}
return tagRepository.save(tag);
Tag saved = tagRepository.save(tag);
searchIndexEventPublisher.publishTagSaved(saved);
return saved;
}
@CacheEvict(value = CachingConfig.TAG_CACHE_NAME, allEntries = true)
public void deleteTag(Long id) {
tagRepository.deleteById(id);
searchIndexEventPublisher.publishTagDeleted(id);
}
public Tag approveTag(Long id) {
@@ -91,7 +98,9 @@ public class TagService {
.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Tag not found"));
tag.setApproved(true);
return tagRepository.save(tag);
Tag saved = tagRepository.save(tag);
searchIndexEventPublisher.publishTagSaved(saved);
return saved;
}
public List<Tag> listPendingTags() {

View File

@@ -5,6 +5,7 @@ import com.openisle.exception.FieldException;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.AvatarGenerator;
import com.openisle.service.PasswordValidator;
import com.openisle.service.UsernameValidator;
@@ -34,6 +35,7 @@ public class UserService {
private final RedisTemplate redisTemplate;
private final EmailSender emailService;
private final SearchIndexEventPublisher searchIndexEventPublisher;
public User register(
String username,
@@ -58,7 +60,9 @@ public class UserService {
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
User saved = userRepository.save(u);
searchIndexEventPublisher.publishUserSaved(saved);
return saved;
}
// ── 再按邮箱查 ───────────────────────────────────────────
@@ -75,7 +79,9 @@ public class UserService {
// u.setVerificationCode(genCode());
u.setRegisterReason(reason);
u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(u);
User saved = userRepository.save(u);
searchIndexEventPublisher.publishUserSaved(saved);
return saved;
}
// ── 完全新用户 ───────────────────────────────────────────
@@ -89,14 +95,18 @@ public class UserService {
user.setAvatar(avatarGenerator.generate(username));
user.setRegisterReason(reason);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
return userRepository.save(user);
User saved = userRepository.save(user);
searchIndexEventPublisher.publishUserSaved(saved);
return saved;
}
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
// user.setVerificationCode(genCode());
return userRepository.save(user);
User saved = userRepository.save(user);
searchIndexEventPublisher.publishUserSaved(saved);
return saved;
}
private String genCode() {
@@ -209,7 +219,9 @@ public class UserService {
.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
user.setRegisterReason(reason);
return userRepository.save(user);
User saved = userRepository.save(user);
searchIndexEventPublisher.publishUserSaved(saved);
return saved;
}
public User updateProfile(String currentUsername, String newUsername, String introduction) {

View File

@@ -4,7 +4,7 @@ server.port=${SERVER_PORT:8080}
# for mysql
logging.level.root=${LOG_LEVEL:INFO}
logging.level.com.openisle.service.CosImageUploader=DEBUG
spring.datasource.url=${MYSQL_URL:jdbc:mysql://localhost:3306/openisle}
spring.datasource.url=jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE}?useUnicode=yes&characterEncoding=UTF-8&useInformationSchema=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=${MYSQL_USER:root}
spring.datasource.password=${MYSQL_PASSWORD:password}
spring.jpa.hibernate.ddl-auto=update
@@ -45,6 +45,18 @@ app.user.replies-limit=${USER_REPLIES_LIMIT:50}
# Length of extracted snippets for posts and search (-1 to disable truncation)
app.snippet-length=${SNIPPET_LENGTH:200}
# OpenSearch integration
app.search.enabled=${SEARCH_ENABLED:true}
app.search.host=${OPENSEARCH_HOST:opensearch}
app.search.port=${OPENSEARCH_PORT:9200}
app.search.scheme=${OPENSEARCH_SCHEME:http}
app.search.username=${OPENSEARCH_USERNAME:}
app.search.password=${OPENSEARCH_PASSWORD:}
app.search.index-prefix=${SEARCH_INDEX_PREFIX:openisle}
app.search.highlight-fragment-size=${SEARCH_HIGHLIGHT_FRAGMENT_SIZE:${SNIPPET_LENGTH:200}}
app.search.reindex-on-startup=${SEARCH_REINDEX_ON_STARTUP:true}
app.search.reindex-batch-size=${SEARCH_REINDEX_BATCH_SIZE:500}
# Captcha configuration
app.captcha.enabled=${CAPTCHA_ENABLED:false}
recaptcha.secret-key=${RECAPTCHA_SECRET_KEY:}
@@ -69,15 +81,15 @@ cos.bucket-name=${COS_BUCKET_NAME:}
# your image upload services: ...
# Google OAuth configuration
google.client-id=${GOOGLE_CLIENT_ID:}
google.client-id=${NUXT_PUBLIC_GOOGLE_CLIENT_ID:}
# GitHub OAuth configuration
github.client-id=${GITHUB_CLIENT_ID:}
github.client-id=${NUXT_PUBLIC_GITHUB_CLIENT_ID:}
github.client-secret=${GITHUB_CLIENT_SECRET:}
# Discord OAuth configuration
discord.client-id=${DISCORD_CLIENT_ID:}
discord.client-id=${NUXT_PUBLIC_DISCORD_CLIENT_ID:}
discord.client-secret=${DISCORD_CLIENT_SECRET:}
# Twitter OAuth configuration
twitter.client-id=${TWITTER_CLIENT_ID:}
twitter.client-id=${NUXT_PUBLIC_TWITTER_CLIENT_ID:}
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
# Telegram login configuration
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
@@ -117,3 +129,6 @@ springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1
springdoc.info.scheme=Bearer
springdoc.info.header=Authorization
management.endpoints.web.exposure.include=health,info
management.endpoint.health.probes.enabled=true

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,9 +68,9 @@ class SearchControllerTest {
c.setContent("nice");
Mockito.when(searchService.globalSearch("n")).thenReturn(
List.of(
new SearchService.SearchResult("user", 1L, "bob", null, null, null),
new SearchService.SearchResult("post", 2L, "hello", null, null, null),
new SearchService.SearchResult("comment", 3L, "nice", null, null, null)
new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null),
new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null),
new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null)
)
);

View File

@@ -82,6 +82,7 @@ class TagControllerTest {
t.setIcon("i2");
t.setSmallIcon("s2");
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t));
Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(java.util.Map.of());
mockMvc
.perform(get("/api/tags"))
@@ -93,6 +94,31 @@ class TagControllerTest {
.andExpect(jsonPath("$[0].smallIcon").value("s2"));
}
@Test
void listTagsWithPagination() throws Exception {
Tag t1 = new Tag();
t1.setId(1L);
t1.setName("java");
Tag t2 = new Tag();
t2.setId(2L);
t2.setName("spring");
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t1, t2));
Mockito.when(postService.countPostsByTagIds(List.of(1L, 2L))).thenReturn(
java.util.Map.of(1L, 1L, 2L, 5L)
);
mockMvc
.perform(get("/api/tags").param("page", "1").param("pageSize", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(1)))
.andExpect(jsonPath("$[0].id").value(1));
mockMvc
.perform(get("/api/tags").param("page", "2").param("pageSize", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", org.hamcrest.Matchers.hasSize(0)));
}
@Test
void updateTag() throws Exception {
Tag t = new Tag();

View File

@@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.openisle.dto.CommentInfoDto;
import com.openisle.dto.PostMetaDto;
import com.openisle.dto.UserDto;
import com.openisle.mapper.CategoryMapper;
import com.openisle.mapper.TagMapper;
import com.openisle.mapper.UserMapper;
import com.openisle.model.User;
@@ -64,6 +65,9 @@ class UserControllerTest {
@MockBean
private TagMapper tagMapper;
@MockBean
private CategoryMapper categoryMapper;
@Test
void getCurrentUser() throws Exception {
User u = new User();

View File

@@ -11,6 +11,7 @@ import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.SearchIndexEventPublisher;
import com.openisle.service.PointService;
import org.junit.jupiter.api.Test;
@@ -29,6 +30,7 @@ class CommentServiceTest {
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
PointService pointService = mock(PointService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
CommentService service = new CommentService(
commentRepo,
@@ -41,7 +43,8 @@ class CommentServiceTest {
nRepo,
pointHistoryRepo,
pointService,
imageUploader
imageUploader,
searchIndexEventPublisher
);
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);

View File

@@ -6,6 +6,7 @@ import static org.mockito.Mockito.*;
import com.openisle.exception.RateLimitException;
import com.openisle.model.*;
import com.openisle.repository.*;
import com.openisle.search.SearchIndexEventPublisher;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@@ -42,6 +43,7 @@ class PostServiceTest {
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
PostService service = new PostService(
postRepo,
@@ -67,7 +69,8 @@ class PostServiceTest {
postChangeLogService,
pointHistoryRepository,
PublishMode.DIRECT,
redisTemplate
redisTemplate,
searchIndexEventPublisher
);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -118,6 +121,7 @@ class PostServiceTest {
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
PostService service = new PostService(
postRepo,
@@ -143,7 +147,8 @@ class PostServiceTest {
postChangeLogService,
pointHistoryRepository,
PublishMode.DIRECT,
redisTemplate
redisTemplate,
searchIndexEventPublisher
);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -207,6 +212,7 @@ class PostServiceTest {
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
PostService service = new PostService(
postRepo,
@@ -232,7 +238,8 @@ class PostServiceTest {
postChangeLogService,
pointHistoryRepository,
PublishMode.DIRECT,
redisTemplate
redisTemplate,
searchIndexEventPublisher
);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -283,6 +290,7 @@ class PostServiceTest {
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
PostService service = new PostService(
postRepo,
@@ -308,7 +316,8 @@ class PostServiceTest {
postChangeLogService,
pointHistoryRepository,
PublishMode.DIRECT,
redisTemplate
redisTemplate,
searchIndexEventPublisher
);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -375,6 +384,7 @@ class PostServiceTest {
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
PointHistoryRepository pointHistoryRepository = mock(PointHistoryRepository.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
SearchIndexEventPublisher searchIndexEventPublisher = mock(SearchIndexEventPublisher.class);
PostService service = new PostService(
postRepo,
@@ -400,7 +410,8 @@ class PostServiceTest {
postChangeLogService,
pointHistoryRepository,
PublishMode.DIRECT,
redisTemplate
redisTemplate,
searchIndexEventPublisher
);
when(context.getBean(PostService.class)).thenReturn(service);

View File

@@ -9,7 +9,9 @@ import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
import com.openisle.repository.TagRepository;
import com.openisle.repository.UserRepository;
import com.openisle.search.OpenSearchProperties;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
@@ -27,7 +29,9 @@ class SearchServiceTest {
postRepo,
commentRepo,
categoryRepo,
tagRepo
tagRepo,
Optional.empty(),
new OpenSearchProperties()
);
Post post1 = new Post();

56
deploy/deploy.sh Normal file
View File

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

57
deploy/deploy_staging.sh Normal file
View File

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

View File

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

1
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
data

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
# opensearch
FROM opensearchproject/opensearch:3.0.0
RUN /usr/share/opensearch/bin/opensearch-plugin install -b analysis-icu
RUN /usr/share/opensearch/bin/opensearch-plugin install -b \
https://github.com/aparo/opensearch-analysis-pinyin/releases/download/3.0.0/opensearch-analysis-pinyin.zip
# ...

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
import Link from "next/link";
import { getOpenAPIOperations } from "@/lib/openapi-operations";
const methodColors: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-purple-100 text-purple-700",
DELETE: "bg-rose-100 text-rose-700",
};
function MethodBadge({ method }: { method: string }) {
const color = methodColors[method] ?? "bg-slate-100 text-slate-700";
return (
<span
className={`font-semibold uppercase tracking-wide text-xs px-2 py-1 rounded ${color}`}
>
{method}
</span>
);
}
export function APIOverviewTable() {
const operations = getOpenAPIOperations();
if (operations.length === 0) {
return null;
}
return (
<div className="not-prose mt-6 overflow-x-auto">
<table className="w-full border-separate border-spacing-y-2 text-sm">
<thead className="text-left text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr
key={`${operation.method}-${operation.route}`}
className="bg-muted/30"
>
<td className="px-3 py-2 align-top font-mono">
<Link className="hover:underline" href={operation.href}>
{operation.route}
</Link>
</td>
<td className="px-3 py-2 align-top">
<MethodBadge method={operation.method} />
</td>
<td className="px-3 py-2 align-top text-muted-foreground">
{operation.summary || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -2,3 +2,11 @@
title: API 概览
description: Open API 接口文档
---
import { APIOverviewTable } from "@/components/api-overview";
# 接口列表
以下列表聚合了所有已生成的接口页面,展示对应的路径、请求方法以及摘要,便于快速检索和跳转。
<APIOverviewTable />

View File

@@ -0,0 +1,68 @@
import matter from "gray-matter";
import { source } from "@/lib/source";
interface OperationFrontmatter {
title?: string;
description?: string;
_openapi?: {
method?: string;
route?: string;
};
}
export interface OpenAPIOperation {
href: string;
method: string;
route: string;
summary: string;
}
function parseFrontmatter(content: string): OperationFrontmatter {
const result = matter(content);
return result.data as OperationFrontmatter;
}
function normalizeSummary(frontmatter: OperationFrontmatter): string {
return frontmatter.title ?? frontmatter.description ?? "";
}
export function getOpenAPIOperations(): OpenAPIOperation[] {
return source
.getPages()
.filter((page) =>
page.url.startsWith("/openapi/") && page.url !== "/openapi"
)
.map((page) => {
if (typeof page.data.content !== "string") {
return undefined;
}
const frontmatter = parseFrontmatter(page.data.content);
const method = frontmatter._openapi?.method?.toUpperCase();
const route = frontmatter._openapi?.route;
const summary = normalizeSummary(frontmatter);
if (!method || !route) {
return undefined;
}
return {
href: page.url,
method,
route,
summary,
} satisfies OpenAPIOperation;
})
.filter((operation): operation is OpenAPIOperation => Boolean(operation))
.sort((a, b) => {
const routeCompare = a.route.localeCompare(b.route);
if (routeCompare !== 0) {
return routeCompare;
}
return a.method.localeCompare(b.method);
});
}

View File

@@ -1,10 +1,18 @@
import { rmSync } from "node:fs";
import { generateFiles } from "fumadocs-openapi";
import { openapi } from "@/lib/openapi";
const outputDir = "./content/docs/openapi/(generated)";
rmSync(outputDir, { recursive: true, force: true });
void generateFiles({
input: openapi,
output: "./content/docs/openapi/(generated)",
output: outputDir,
// we recommend to enable it
// make sure your endpoint description doesn't break MDX syntax.
includeDescription: true,
per: "operation",
groupBy: "route",
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -108,7 +108,6 @@ body {
.vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important;
z-index: 20;
}
.vditor-panel {
@@ -121,26 +120,19 @@ body {
vertical-align: middle;
}
/* .vditor {
--textarea-background-color: transparent;
border: none !important;
box-shadow: none !important;
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.vditor-reset {
color: var(--text-color);
.loading-icon {
animation: spin 1s linear infinite;
}
.vditor-toolbar {
background: transparent !important;
border: none !important;
box-shadow: none !important;
} */
/* .vditor-toolbar {
position: relative !important;
} */
/*************************
* Markdown 渲染样式
*************************/
@@ -320,10 +312,6 @@ body {
min-height: 100px;
}
.vditor-toolbar {
overflow-x: auto;
}
.about-content h1,
.info-content-text h1 {
font-size: 20px;
@@ -341,8 +329,8 @@ body {
margin-bottom: 3px;
}
.vditor-toolbar--pin {
top: 0 !important;
.vditor-panel {
min-width: 330px;
}
.about-content li,
@@ -354,11 +342,6 @@ body {
line-height: 1.5;
}
.vditor-panel {
position: relative;
min-width: 0;
}
.d2h-file-name {
font-size: 14px !important;
}

View File

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

View File

@@ -1,12 +1,20 @@
<template>
<div class="timeline" :class="{ 'hover-enabled': hover }">
<div class="timeline">
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
<div
class="timeline-icon"
:class="{ clickable: !!item.iconClick }"
@click="item.iconClick && item.iconClick()"
:class="{ clickable: !!item.iconClick || hasLink(item) }"
@click="onIconClick(item, $event)"
>
<BaseImage v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<BaseUserAvatar
v-if="item.src"
:src="item.src"
:user-id="item.userId"
:to="item.avatarLink"
class="timeline-img"
alt="timeline item"
:disable-link="!hasLink(item) || !!item.iconClick"
/>
<component
v-else-if="item.icon && (typeof item.icon !== 'string' || !item.icon.includes(' '))"
:is="item.icon"
@@ -22,11 +30,27 @@
</template>
<script>
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
export default {
name: 'BaseTimeline',
components: { BaseUserAvatar },
props: {
items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
},
methods: {
hasLink(item) {
if (!item) return false
if (item.avatarLink) return true
const id = item?.userId
return id !== undefined && id !== null && id !== ''
},
onIconClick(item, event) {
if (item && item.iconClick) {
event.preventDefault()
item.iconClick()
}
},
},
}
</script>
@@ -46,12 +70,6 @@ export default {
margin-top: 10px;
}
.hover-enabled .timeline-item:hover {
background-color: var(--menu-selected-background-color);
transition: background-color 0.2s;
border-radius: 10px;
}
.timeline-icon {
position: sticky;
top: 0;
@@ -73,8 +91,12 @@ export default {
.timeline-img {
width: 100%;
height: 100%;
}
.timeline-img :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.timeline-emoji {
@@ -95,7 +117,7 @@ export default {
}
.timeline-item:last-child::before {
display: none;
bottom: 0px;
}
.timeline-content {

View File

@@ -0,0 +1,134 @@
<template>
<NuxtLink
:to="resolvedLink"
class="base-user-avatar"
:class="wrapperClass"
:style="wrapperStyle"
v-bind="wrapperAttrs"
>
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
</NuxtLink>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useAttrs } from 'vue'
import BaseImage from './BaseImage.vue'
const DEFAULT_AVATAR = '/default-avatar.svg'
const props = defineProps({
userId: {
type: [String, Number],
default: null,
},
src: {
type: String,
default: '',
},
alt: {
type: String,
default: '',
},
width: {
type: [Number, String],
default: null,
},
rounded: {
type: Boolean,
default: true,
},
disableLink: {
type: Boolean,
default: false,
},
to: {
type: String,
default: '',
},
})
const attrs = useAttrs()
const currentSrc = ref(props.src || DEFAULT_AVATAR)
watch(
() => props.src,
(value) => {
currentSrc.value = value || DEFAULT_AVATAR
},
)
const resolvedLink = computed(() => {
if (props.to) return props.to
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
return `/users/${props.userId}`
}
return null
})
const altText = computed(() => props.alt || '用户头像')
const sizeStyle = computed(() => {
if (!props.width && props.width !== 0) return null
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
if (!value) return null
return { width: value, height: value }
})
const wrapperStyle = computed(() => {
const attrStyle = attrs.style
return [sizeStyle.value, attrStyle]
})
const wrapperClass = computed(() => [attrs.class, { 'is-rounded': props.rounded }])
const wrapperAttrs = computed(() => {
const { class: _class, style: _style, ...rest } = attrs
return rest
})
function onError() {
if (currentSrc.value !== DEFAULT_AVATAR) {
currentSrc.value = DEFAULT_AVATAR
}
}
</script>
<style scoped>
.base-user-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--avatar-placeholder-color, #f0f0f0);
/* 先用box-sizing: border-box保证加border后宽高不变圆形不变形 */
box-sizing: border-box;
border: 1.5px solid var(--normal-border-color);
transition: all 0.6s ease;
}
.base-user-avatar:hover {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
transform: scale(1.05);
}
.base-user-avatar:active {
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
}
.base-user-avatar.is-rounded {
border-radius: 50%;
}
.base-user-avatar:not(.is-rounded) {
border-radius: 0;
}
.base-user-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>

View File

@@ -10,7 +10,7 @@
发布评论
<span class="shortcut-icon" v-if="!isMobile"> {{ isMac ? '' : 'Ctrl' }} </span>
</template>
<template v-else> <loading-four /> 发布中... </template>
<template v-else> <loading-four class="loading-icon" /> 发布中... </template>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@
<div class="common-info-content-header">
<div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span>
<span v-if="isCommentFromPostAuthor" class="op-badge" title="楼主">OP</span>
<medal-one class="medal-icon" />
<NuxtLink
v-if="comment.medal"
@@ -26,11 +27,12 @@
<span v-if="level >= 2" class="reply-item">
<next class="reply-icon" />
<span class="reply-info">
<BaseImage
<BaseUserAvatar
class="reply-avatar"
:src="comment.parentUserAvatar || '/default-avatar.svg'"
alt="avatar"
@click="comment.parentUserClick && comment.parentUserClick()"
:src="comment.parentUserAvatar"
:user-id="comment.parentUserId"
:alt="comment.parentUserName"
:disable-link="!comment.parentUserId"
/>
<span class="reply-user-name">{{ comment.parentUserName }}</span>
</span>
@@ -111,6 +113,7 @@ import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -155,6 +158,12 @@ const lightboxImgs = ref([])
const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || []))
const isCommentFromPostAuthor = computed(() => {
if (props.comment.userId == null || props.postAuthorId == null) {
return false
}
return String(props.comment.userId) === String(props.postAuthorId)
})
const toggleReplies = () => {
showReplies.value = !showReplies.value
@@ -259,6 +268,7 @@ const submitReply = async (parentUserName, text, clear) => {
text: data.content,
parentUserName: parentUserName,
parentUserAvatar: props.comment.avatar,
parentUserId: props.comment.userId,
reactions: [],
reply: (data.replies || []).map((r) => ({
id: r.id,
@@ -270,10 +280,12 @@ const submitReply = async (parentUserName, text, clear) => {
reply: [],
openReplies: false,
src: r.author.avatar,
userId: r.author.id,
iconClick: () => navigateTo(`/users/${r.author.id}`),
})),
openReplies: false,
src: data.author.avatar,
userId: data.author.id,
iconClick: () => navigateTo(`/users/${data.author.id}`),
})
clear()
@@ -421,6 +433,21 @@ const handleContentClick = (e) => {
color: var(--text-color);
}
.op-badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
padding: 0 6px;
height: 18px;
border-radius: 9px;
background-color: rgba(242, 100, 25, 0.12);
color: #f26419;
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.medal-icon {
font-size: 12px;
opacity: 0.6;

View File

@@ -52,6 +52,7 @@
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
:class="['dropdown-menu', menuClass]"
v-click-outside="close"
ref="menuRef"
>
<div v-if="showSearch" class="dropdown-search">
<search-icon class="search-icon" />
@@ -80,6 +81,7 @@
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</div>
<Teleport to="body">
@@ -88,7 +90,7 @@
<next class="back-icon" @click="close" />
<span class="mobile-title">{{ placeholder }}</span>
</div>
<div class="dropdown-mobile-menu">
<div class="dropdown-mobile-menu" ref="mobileMenuRef">
<div v-if="showSearch" class="dropdown-search">
<search-icon class="search-icon" />
<input type="text" v-model="search" placeholder="搜索" />
@@ -116,6 +118,7 @@
<span>{{ o.name }}</span>
</slot>
</div>
<slot name="footer" :close="close" :loading="loading" />
</template>
</div>
</div>
@@ -151,6 +154,8 @@ export default {
const loaded = ref(false)
const loading = ref(false)
const wrapper = ref(null)
const menuRef = ref(null)
const mobileMenuRef = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
@@ -200,6 +205,17 @@ export default {
}
}
const scrollToBottom = () => {
const el = isMobile.value ? mobileMenuRef.value : menuRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
const reload = async () => {
await loadOptions(props.remote ? search.value : undefined)
}
watch(
() => props.initialOptions,
(val) => {
@@ -249,7 +265,7 @@ export default {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
expose({ toggle, close })
expose({ toggle, close, reload, scrollToBottom })
return {
open,
@@ -259,6 +275,8 @@ export default {
search,
filteredOptions,
wrapper,
menuRef,
mobileMenuRef,
selectedLabels,
isSelected,
loading,
@@ -279,6 +297,7 @@ export default {
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px 10px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
@@ -297,8 +316,9 @@ export default {
right: 0;
background: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 5px;
z-index: 10000;
max-height: 200px;
max-height: 300px;
min-width: 350px;
overflow-y: auto;
}

View File

@@ -70,7 +70,14 @@
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
<template #trigger>
<div class="avatar-container">
<img class="avatar-img" :src="avatar" alt="avatar" />
<BaseUserAvatar
class="avatar-img"
:user-id="authState.userId"
:src="avatar"
alt="avatar"
:width="32"
:disable-link="true"
/>
<down />
</div>
</template>
@@ -93,6 +100,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'

View File

@@ -116,30 +116,42 @@
<div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div
v-else
v-for="t in tagData"
:key="t.id"
class="section-item"
:class="{ selected: isTagSelected(t.id) }"
<template v-else>
<div
v-for="t in tagData"
:key="t.id"
class="section-item"
:class="{ selected: isTagSelected(t.id) }"
@click="gotoTag(t)"
>
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<component
v-else-if="t.smallIcon || t.icon"
:is="t.smallIcon || t.icon"
class="section-item-icon"
/>
<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
>
</div>
<BaseImage
v-if="isImageIcon(t.smallIcon || t.icon)"
:src="t.smallIcon || t.icon"
class="section-item-icon"
:alt="t.name"
/>
<component
v-else-if="t.smallIcon || t.icon"
:is="t.smallIcon || t.icon"
class="section-item-icon"
/>
<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
>
</div>
<div v-if="hasMoreTags || isLoadingMoreTags" class="section-item more-item">
<a
v-if="hasMoreTags && !isLoadingMoreTags"
href="#"
class="more-link"
@click.prevent="loadMoreTags"
>
查看更多
</a>
<span v-else class="more-loading">加载中...</span>
</div>
</template>
</div>
</div>
</div>
@@ -207,16 +219,88 @@ const {
},
)
const TAG_PAGE_SIZE = 10
const tagPage = ref(0)
const hasMoreTags = ref(true)
const isLoadingMoreTags = ref(false)
const buildTagUrl = (page = 0) => {
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
url.searchParams.set('page', String(page))
url.searchParams.set('pageSize', String(TAG_PAGE_SIZE))
return url.toString()
}
const fetchTagPage = async (page = 0) => {
try {
return await $fetch(buildTagUrl(page))
} catch (e) {
console.error('Failed to fetch tags', e)
return []
}
}
const {
data: tagData,
pending: isLoadingTag,
error: tagError,
} = await useAsyncData('menu:tags', () => $fetch(`${API_BASE_URL}/api/tags?limit=10`), {
} = await useAsyncData('menu:tags', () => fetchTagPage(0), {
server: true,
default: () => [],
staleTime: 5 * 60 * 1000,
})
const dedupeTags = (list) => Array.from(new Map(list.map((tag) => [tag.id, tag])).values())
const initializeTagState = (val) => {
const initial = Array.isArray(val) ? val : []
if (!Array.isArray(val)) {
tagData.value = []
}
tagPage.value = 0
hasMoreTags.value = initial.length === TAG_PAGE_SIZE
}
initializeTagState(tagData.value)
watch(
tagData,
(val, oldVal) => {
const next = Array.isArray(val) ? val : []
if (!Array.isArray(val)) {
tagData.value = []
}
const shouldReset =
!Array.isArray(oldVal) || oldVal.length > next.length || next.length <= TAG_PAGE_SIZE
if (shouldReset) {
tagPage.value = 0
hasMoreTags.value = next.length === TAG_PAGE_SIZE
}
},
{ deep: false },
)
const loadMoreTags = async () => {
if (isLoadingMoreTags.value || !hasMoreTags.value) return
isLoadingMoreTags.value = true
const nextPage = tagPage.value + 1
try {
const result = await fetchTagPage(nextPage)
const data = Array.isArray(result) ? result : []
const existing = Array.isArray(tagData.value) ? tagData.value : []
tagData.value = dedupeTags([...existing, ...data])
tagPage.value = nextPage
if (data.length < TAG_PAGE_SIZE) {
hasMoreTags.value = false
}
} catch (e) {
console.error('Failed to load more tags', e)
} finally {
isLoadingMoreTags.value = false
}
}
/** 其余逻辑保持不变 */
const iconClass = computed(() => {
switch (themeState.mode) {
@@ -433,6 +517,27 @@ const gotoTag = (t) => {
transition: background-color 0.5s ease;
}
.more-item {
justify-content: center;
}
.more-link {
color: var(--primary-color);
text-decoration: none;
font-size: 14px;
cursor: pointer;
}
.more-link:hover {
text-decoration: underline;
}
.more-loading {
font-size: 13px;
color: var(--menu-text-color);
opacity: 0.7;
}
.section-item:hover {
background-color: var(--menu-selected-background-color-hover);
}
@@ -441,7 +546,6 @@ const gotoTag = (t) => {
background-color: var(--menu-selected-background-color);
}
.section-item-text-count {
font-size: 12px;
color: var(--menu-text-color);

View File

@@ -9,7 +9,7 @@
发送
<span class="shortcut-icon" v-if="!isMobile"> {{ isMac ? '' : 'Ctrl' }} </span>
</template>
<template v-else> <loading-four /> 发送中... </template>
<template v-else> <loading-four class="loading-icon" /> 发送中... </template>
</div>
</div>
</div>
@@ -159,12 +159,6 @@ export default {
border: 1px solid var(--border-color);
border-radius: 8px;
}
.vditor {
min-height: 50px;
max-height: 150px;
}
.message-bottom-container {
display: flex;
flex-direction: row;

View File

@@ -1,12 +1,13 @@
<template>
<div :id="`change-log-${log.id}`" class="change-log-container">
<div class="change-log-text">
<BaseImage
<BaseUserAvatar
v-if="log.userAvatar"
class="change-log-avatar"
:src="log.userAvatar"
:to="log.username ? `/users/${log.username}` : ''"
alt="avatar"
@click="() => navigateTo(`/users/${log.username}`)"
:disable-link="!log.username"
/>
<span v-if="log.username" class="change-log-user">{{ log.username }}</span>
<span v-if="log.type === 'CONTENT'" class="change-log-content">变更了文章内容</span>
@@ -55,10 +56,8 @@
import { computed } from 'vue'
import { html } from 'diff2html'
import { createTwoFilesPatch } from 'diff'
import { useIsMobile } from '~/utils/screen'
import 'diff2html/bundles/css/diff2html.min.css'
import BaseImage from '~/components/BaseImage.vue'
import { navigateTo } from 'nuxt/app'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { themeState } from '~/utils/theme'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
@@ -135,6 +134,12 @@ const diffHtml = computed(() => {
cursor: pointer;
}
.change-log-avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.change-log-time {
font-size: 12px;
opacity: 0.6;

View File

@@ -53,24 +53,24 @@
</div>
</div>
<div class="prize-member-container">
<BaseImage
<BaseUserAvatar
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:user-id="p.id"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<medal-one class="medal-icon"></medal-one>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
<BaseUserAvatar
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:user-id="w.id"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
@@ -87,6 +87,7 @@ import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useIsMobile } from '~/utils/screen'
import { useCountdown } from '~/composables/useCountdown'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const props = defineProps({
lottery: { type: Object, required: true },
@@ -106,8 +107,6 @@ const hasJoined = computed(() => {
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const joinLottery = async () => {
@@ -247,10 +246,15 @@ const joinLottery = async () => {
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.prize-member-winner {
display: flex;
flex-direction: row;

View File

@@ -17,13 +17,13 @@
></div>
</div>
<div class="poll-participants">
<BaseImage
<BaseUserAvatar
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:user-id="p.id"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
@@ -119,6 +119,7 @@ import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useCountdown } from '~/composables/useCountdown'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const props = defineProps({
poll: { type: Object, required: true },
@@ -152,8 +153,6 @@ watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const voteOption = async (idx) => {
@@ -429,4 +428,10 @@ const submitMultiPoll = async () => {
border-radius: 50%;
cursor: pointer;
}
.poll-participant-avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View File

@@ -1,168 +0,0 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">{{ headerText }}</div>
<div class="timeline-date">{{ headerDate }}</div>
</div>
<div class="comment-content">
<div v-for="entry in entries" :key="entry.comment.id" class="comment-content-item">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<template v-if="!entry.comment.parentComment">
<span class="comment-prefix">
<NuxtLink :to="entry.postLink" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
下评论了
</span>
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</template>
<template v-else>
<span class="comment-prefix">
<NuxtLink :to="entry.postLink" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
下对
<NuxtLink :to="entry.parentLink" class="timeline-link">
{{ stripContent(entry.comment.parentComment.content) }}
</NuxtLink>
回复了
</span>
<NuxtLink :to="entry.commentLink" class="timeline-comment-link">
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</template>
</div>
<div class="timeline-date">{{ formatDate(entry.createdAt) }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: {
type: Object,
required: true,
},
})
const entries = computed(() =>
(props.item.entries || []).map((entry) => ({
...entry,
postLink: `/posts/${entry.comment.post.id}`,
commentLink: `/posts/${entry.comment.post.id}#comment-${entry.comment.id}`,
parentLink: entry.comment.parentComment
? `/posts/${entry.comment.post.id}#comment-${entry.comment.parentComment.id}`
: undefined,
})),
)
const commentCount = computed(
() => entries.value.filter((entry) => !entry.comment.parentComment).length,
)
const replyCount = computed(
() => entries.value.filter((entry) => entry.comment.parentComment).length,
)
const headerText = computed(() => {
if (commentCount.value && replyCount.value) {
return `发布了${commentCount.value}条评论和${replyCount.value}条回复`
}
if (commentCount.value) {
return `发布了${commentCount.value}条评论`
}
if (replyCount.value) {
return `发布了${replyCount.value}条回复`
}
return '发布了评论'
})
const headerDate = computed(() => TimeManager.format(props.item.createdAt))
const formatDate = (date) => TimeManager.format(date)
const stripContent = (content) => stripMarkdownLength(content || '', 200)
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-weight: 600;
color: var(--primary-color);
}
.timeline-date {
color: var(--text-color-secondary);
font-size: 14px;
}
.comment-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-content-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.comment-content-item-main {
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-content-item-icon {
color: var(--primary-color);
font-size: 18px;
}
.comment-prefix {
color: var(--text-color-secondary);
font-size: 14px;
}
.timeline-comment-link {
display: inline-flex;
gap: 6px;
align-items: center;
color: var(--text-color);
font-size: 15px;
line-height: 1.6;
}
.timeline-comment-link:hover {
color: var(--primary-color);
}
.timeline-link {
color: var(--primary-color);
}
.timeline-link:hover {
color: var(--primary-color-hover);
}
</style>

View File

@@ -1,79 +0,0 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">发布了文章</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="article-container">
<NuxtLink :to="postLink" class="timeline-article-link">
{{ props.item.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ postSnippet }}
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: {
type: Object,
required: true,
},
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const postLink = computed(() => `/posts/${props.item.post.id}`)
const postSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-weight: 600;
color: var(--primary-color);
}
.timeline-date {
color: var(--text-color-secondary);
font-size: 14px;
}
.article-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--text-color);
}
.timeline-article-link:hover {
color: var(--primary-color);
}
.timeline-snippet {
color: var(--text-color-secondary);
font-size: 14px;
line-height: 1.6;
}
</style>

View File

@@ -26,9 +26,20 @@
<div class="search-option-item">
<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>
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
<div
class="result-main"
v-html="renderHighlight(option.highlightedText, option.text)"
></div>
<div
v-if="option.subText"
class="result-sub"
v-html="renderHighlight(option.highlightedSubText, option.subText)"
></div>
<div
v-if="option.extra"
class="result-extra"
v-html="renderHighlight(option.highlightedExtra, option.extra)"
></div>
</div>
</div>
</template>
@@ -70,16 +81,30 @@ const fetchResults = async (kw) => {
subText: r.subText,
extra: r.extra,
postId: r.postId,
highlightedText: r.highlightedText,
highlightedSubText: r.highlightedSubText,
highlightedExtra: r.highlightedExtra,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text)
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
return res
const escapeHtml = (value = '') =>
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const renderHighlight = (highlighted, fallback) => {
if (highlighted) {
return highlighted
}
const plain = stripMarkdown(fallback || '')
if (!plain) {
return ''
}
return escapeHtml(plain)
}
const iconMap = {
@@ -168,7 +193,7 @@ defineExpose({
padding: 10px 20px;
}
:deep(.highlight) {
:deep(mark) {
color: var(--primary-color);
}

View File

@@ -24,17 +24,22 @@
</template>
<template #option="{ option }">
<div class="search-option-item">
<BaseImage
:src="option.avatar || '/default-avatar.svg'"
<BaseUserAvatar
:src="option.avatar"
:user-id="option.id"
:alt="option.username"
class="avatar"
@error="handleAvatarError"
:disable-link="true"
/>
<div class="result-body">
<div class="result-main" v-html="highlight(option.username)"></div>
<div
class="result-main"
v-html="renderHighlight(option.highlightedUsername, option.username)"
></div>
<div
v-if="option.introduction"
class="result-sub"
v-html="highlight(option.introduction)"
v-html="renderHighlight(option.highlightedIntroduction, option.introduction)"
></div>
</div>
</div>
@@ -49,6 +54,7 @@ import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import { getToken } from '~/utils/auth'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -76,19 +82,29 @@ const fetchResults = async (kw) => {
username: u.username,
avatar: u.avatar,
introduction: u.introduction,
highlightedUsername: u.highlightedText,
highlightedIntroduction: u.highlightedSubText || u.highlightedExtra,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text || '')
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
}
const escapeHtml = (value = '') =>
String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const handleAvatarError = (e) => {
e.target.src = '/default-avatar.svg'
const renderHighlight = (highlighted, fallback) => {
if (highlighted) {
return highlighted
}
const plain = stripMarkdown(fallback || '')
if (!plain) {
return ''
}
return escapeHtml(plain)
}
watch(selected, async (val) => {
@@ -171,7 +187,7 @@ defineExpose({
padding: 10px 20px;
}
:deep(.highlight) {
:deep(mark) {
color: var(--primary-color);
}
@@ -179,6 +195,12 @@ defineExpose({
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
}
.avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -1,5 +1,6 @@
<template>
<Dropdown
ref="dropdownRef"
v-model="selected"
:fetch-options="fetchTags"
multiple
@@ -25,11 +26,23 @@
<div v-if="option.description" class="option-desc">{{ option.description }}</div>
</div>
</template>
<template #footer>
<div v-if="hasMoreRemoteTags" class="dropdown-footer">
<a
href="#"
class="dropdown-more"
:class="{ disabled: loadMoreRequested }"
@click.prevent="loadMoreRemoteTags"
>
{{ loadMoreRequested ? '加载中...' : '查看更多' }}
</a>
</div>
</template>
</Dropdown>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, reactive, ref, watch, nextTick } from 'vue'
import { toast } from '~/main'
import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
@@ -42,9 +55,19 @@ const props = defineProps({
options: { type: Array, default: () => [] },
})
const dropdownRef = ref(null)
const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
const TAG_PAGE_SIZE = 10
const remoteState = reactive({
keyword: '',
nextPage: 0,
hasMore: true,
options: [],
})
const loadMoreRequested = ref(false)
watch(
() => props.options,
(val) => {
@@ -53,7 +76,7 @@ watch(
)
const mergedOptions = computed(() => {
const arr = [...providedTags.value, ...localTags.value]
const arr = [...providedTags.value, ...localTags.value, ...remoteState.options]
return arr.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
})
@@ -62,44 +85,93 @@ const isImageIcon = (icon) => {
return /^https?:\/\//.test(icon) || icon.startsWith('/')
}
const buildTagsUrl = (kw = '') => {
const buildTagsUrl = (kw = '', page = 0) => {
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)
url.searchParams.set('limit', '10')
url.searchParams.set('page', String(page))
url.searchParams.set('pageSize', String(TAG_PAGE_SIZE))
return url.toString()
}
const fetchRemoteTags = async (kw = '', page = 0) => {
const url = buildTagsUrl(kw, page)
try {
const res = await fetch(url)
if (res.ok) {
const data = await res.json()
return Array.isArray(data) ? data : []
}
throw new Error('failed to fetch tags')
} catch (e) {
console.error('Failed to fetch tags', e)
toast.error('获取标签失败')
throw e
}
}
const combineOptions = (remoteOptions = []) => {
const options = [...providedTags.value, ...localTags.value, ...remoteOptions]
return Array.from(new Map(options.map((t) => [t.id, t])).values())
}
const fetchTags = async (kw = '') => {
const defaultOption = { id: 0, name: '无标签' }
// 1) 先拼 URL自动兜底到 window.location.origin
const url = buildTagsUrl(kw)
// 2) 拉数据
let data = []
try {
const res = await fetch(url)
if (res.ok) data = await res.json()
} catch {
toast.error('获取标签失败')
if (kw !== remoteState.keyword) {
remoteState.keyword = kw
remoteState.nextPage = 0
remoteState.options = []
remoteState.hasMore = true
}
// 3) 合并、去重、可创建
let options = [...data, ...localTags.value]
const shouldFetch = remoteState.options.length === 0 || loadMoreRequested.value
if (shouldFetch) {
const pageToFetch = loadMoreRequested.value ? remoteState.nextPage : 0
try {
const data = await fetchRemoteTags(remoteState.keyword, pageToFetch)
if (pageToFetch === 0) {
remoteState.options = data
} else {
const existing = Array.isArray(remoteState.options) ? remoteState.options : []
const merged = [...existing, ...data]
remoteState.options = Array.from(new Map(merged.map((t) => [t.id, t])).values())
}
remoteState.hasMore = data.length === TAG_PAGE_SIZE
remoteState.nextPage = pageToFetch + 1
} catch (e) {
return [defaultOption, ...combineOptions(remoteState.options)]
} finally {
loadMoreRequested.value = false
}
}
let options = combineOptions(remoteState.options)
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
}
options = Array.from(new Map(options.map((t) => [t.id, t])).values())
// 4) 最终结果
return [defaultOption, ...options]
}
const hasMoreRemoteTags = computed(() => remoteState.hasMore)
const loadMoreRemoteTags = async () => {
if (!remoteState.hasMore || loadMoreRequested.value) return
loadMoreRequested.value = true
try {
await dropdownRef.value?.reload()
await nextTick()
dropdownRef.value?.scrollToBottom?.()
} catch (e) {
console.error('Failed to load more tags', e)
loadMoreRequested.value = false
}
}
const selected = computed({
get: () => props.modelValue,
set: (v) => {
@@ -151,4 +223,21 @@ const selected = computed({
font-weight: bold;
opacity: 0.4;
}
.dropdown-footer {
padding: 8px 20px;
text-align: center;
border-top: 1px solid var(--normal-border-color);
}
.dropdown-more {
color: var(--primary-color);
text-decoration: none;
cursor: pointer;
}
.dropdown-more.disabled {
pointer-events: none;
opacity: 0.6;
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">{{ headerText }}</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="comment-content" v-if="entries.length > 0">
<div class="comment-content-item" v-for="entry in entries" :key="entry.comment.id">
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<div class="comment-content-item-text">
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.id}`"
class="timeline-comment-link"
>
{{ stripContent(entry.comment.content) }}
</NuxtLink>
</div>
</div>
<div class="timeline-date">{{ formatEntryDate(entry.createdAt) }}</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const entries = computed(() => {
if (Array.isArray(props.item.entries)) {
return props.item.entries
}
if (props.item.comment) {
return [
{
type: props.item.type,
comment: props.item.comment,
createdAt: props.item.createdAt,
},
]
}
return []
})
const formattedDate = computed(() => TimeManager.formatWithDay(props.item.createdAt))
const hasReplies = computed(() => entries.value.some((entry) => !!entry.comment.parentComment))
const hasComments = computed(() => entries.value.some((entry) => !entry.comment.parentComment))
const headerText = computed(() => {
const count = entries.value.length
if (count === 0) return ''
if (hasComments.value && hasReplies.value) {
return `发布了${count}条评论/回复`
}
if (hasReplies.value) {
return `发布了${count}条回复`
}
return `发布了${count}条评论`
})
const formatEntryDate = (date) => TimeManager.format(date)
const stripContent = (content) => stripMarkdownLength(content ?? '', 200)
const parentSnippet = (entry) =>
stripMarkdownLength(entry.comment.parentComment?.content ?? '', 200)
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 5px;
padding-bottom: 20px;
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
white-space: nowrap;
}
.comment-content {
display: flex;
flex-direction: column;
gap: 3px;
}
.comment-content-item {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 6px;
}
.comment-content-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.comment-content-item-main {
display: flex;
gap: 8px;
align-items: flex-start;
}
.comment-content-item-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
margin-top: 2px;
}
.comment-content-item-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.comment-content-item-prefix {
font-size: 14px;
color: var(--text-color);
}
.timeline-comment-link {
font-size: 14px;
color: var(--link-color);
text-decoration: underline;
}
.timeline-comment-link:hover {
color: var(--primary-color);
}
.timeline-link {
color: var(--link-color);
text-decoration: none;
}
.timeline-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.comment-content-item-prefix,
.timeline-comment-link {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="timeline-container">
<div class="timeline-header">
<div class="timeline-title">发布了文章</div>
<div class="timeline-date">{{ formattedDate }}</div>
</div>
<div class="article-container">
<NuxtLink :to="postLink" class="timeline-article-link">
{{ item.post?.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ strippedSnippet }}
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<ArticleTags :tags="item.post?.tags" />
<div class="article-comment-count" v-if="item.post?.commentCount !== undefined">
<comment-one class="article-comment-count-icon" />
<span>{{ item.post?.commentCount }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const postLink = computed(() => {
const id = props.item.post?.id
return id ? `/posts/${id}` : '#'
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
const strippedSnippet = computed(() => stripMarkdown(props.item.post?.snippet ?? ''))
const hasMeta = computed(() => {
const tags = props.item.post?.tags ?? []
const hasTags = Array.isArray(tags) && tags.length > 0
const hasCategory = !!props.item.post?.category
const hasCommentCount =
props.item.post?.commentCount !== undefined && props.item.post?.commentCount !== null
return hasTags || hasCategory || hasCommentCount
})
</script>
<style scoped>
.timeline-container {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
.timeline-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-title {
font-size: 16px;
font-weight: 600;
}
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
}
.article-container {
display: flex;
flex-direction: column;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
padding: 10px;
gap: 6px;
}
.timeline-article-link {
font-size: 18px;
font-weight: 600;
color: var(--link-color);
text-decoration: none;
}
.timeline-article-link:hover {
text-decoration: underline;
}
.timeline-snippet {
color: var(--timeline-snippet-color, #666);
font-size: 14px;
line-height: 1.6;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.article-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.article-tag {
background-color: var(--article-info-background-color);
border-radius: 6px;
padding: 2px 6px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-color);
}
.article-comment-count-icon {
width: 16px;
height: 16px;
}
@media (max-width: 768px) {
.timeline-article-link {
font-size: 16px;
}
.timeline-snippet {
font-size: 13px;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="timeline-tag-item">
<div class="tags-container">
<div class="tags-container-item">
<div class="timeline-tag-title">创建了标签</div>
<ArticleTags v-if="tag" :tags="[tag]" />
<span class="timeline-tag-count" v-if="tag?.count"> x{{ tag.count }}</span>
</div>
<div v-if="timelineDate" class="timeline-date">{{ timelineDate }}</div>
</div>
<div v-if="hasDescription" class="timeline-snippet">
{{ tag?.description }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import TimeManager from '~/utils/time'
const props = defineProps({
item: { type: Object, required: true },
})
const emit = defineEmits(['tag-click'])
const tag = computed(() => props.item?.tag ?? null)
const hasDescription = computed(() => {
const description = tag.value?.description
return !!description
})
const timelineDate = computed(() => {
const date = props.item?.createdAt ?? tag.value?.createdAt
return date ? TimeManager.format(date) : ''
})
const summaryDate = computed(() => {
const date = tag.value?.createdAt ?? props.item?.createdAt
return date ? TimeManager.format(date) : ''
})
const isClickable = computed(() => props.mode === 'summary' && !!tag.value)
const handleTagClick = () => {
if (!isClickable.value) return
emit('tag-click', tag.value)
}
</script>
<style scoped>
.timeline-tag-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.tags-container {
display: flex;
flex-direction: row;
gap: 10px;
padding-top: 5px;
justify-content: space-between;
align-items: center;
}
.tags-container-item {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
}
.timeline-tag-title {
font-size: 16px;
font-weight: 600;
}
.timeline-tag-count {
font-size: 12px;
}
.timeline-date {
font-size: 12px;
color: gray;
margin-top: 5px;
white-space: nowrap;
}
.timeline-snippet {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.timeline-link {
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
word-break: break-word;
cursor: default;
}
.timeline-link.clickable {
cursor: pointer;
}
.timeline-link.clickable:hover {
text-decoration: underline;
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="user-list">
<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 v-for="u in users" :key="u.id" class="user-item">
<BaseUserAvatar :src="u.avatar" :user-id="u.id" alt="avatar" class="user-avatar" />
<div class="user-info">
<div class="user-name">{{ u.username }}</div>
<div v-if="u.introduction" class="user-intro">{{ u.introduction }}</div>
@@ -13,6 +13,7 @@
<script setup>
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
defineProps({
users: { type: Array, default: () => [] },
@@ -27,20 +28,27 @@ const handleUserClick = (user) => {
.user-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-item {
padding-top: 20px;
padding-bottom: 20px;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
cursor: pointer;
border-bottom: 1px solid var(--normal-border-color);
}
.user-avatar {
width: 40px;
height: 40px;
width: 50px;
height: 50px;
border-radius: 50%;
flex-shrink: 0;
}
.user-avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {

View File

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

View File

@@ -18,7 +18,9 @@
</div>
</div>
<div class="about-api-title">API文档和调试入口</div>
<div class="about-api-link">API Playground <share /></div>
<a href="http://docs.open-isle.com" target="_blank" rel="noopener" class="about-api-link">
API 文档与 Playground <share />
</a>
</div>
</template>
<template v-else>
@@ -69,6 +71,16 @@ export default {
label: '隐私政策',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
},
{
key: 'points',
label: '积分说明',
content: `# 积分说明
- 积分可用于兑换商品、参与抽奖等社区玩法。
- 管理员可以通过后台新增的积分模块为用户发放奖励积分。
- 每次发放都会记录在积分历史中,方便你查看积分来源。
`,
},
{
key: 'api',
label: 'API与调试',
@@ -86,11 +98,21 @@ export default {
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
})
const loadContent = async (file) => {
if (!file) return
const loadContent = async (tab) => {
if (!tab || tab.key === 'api') return
if (tab.content) {
isFetching.value = false
content.value = tab.content
return
}
if (!tab.file) {
isFetching.value = false
content.value = ''
return
}
try {
isFetching.value = true
const res = await fetch(file)
const res = await fetch(tab.file)
if (res.ok) {
content.value = await res.text()
} else {
@@ -108,15 +130,15 @@ export default {
if (initTab && tabs.find((t) => t.key === initTab)) {
selectedTab.value = initTab
const tab = tabs.find((t) => t.key === initTab)
if (tab && tab.file) loadContent(tab.file)
if (tab) loadContent(tab)
} else {
loadContent(tabs[0].file)
loadContent(tabs[0])
}
})
watch(selectedTab, (name) => {
const tab = tabs.find((t) => t.key === name)
if (tab && tab.file) loadContent(tab.file)
if (tab) loadContent(tab)
router.replace({ query: { ...route.query, tab: name } })
})
@@ -125,6 +147,8 @@ export default {
(name) => {
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
selectedTab.value = name
const tab = tabs.find((t) => t.key === name)
if (tab) loadContent(tab)
}
},
)
@@ -233,6 +257,7 @@ export default {
.about-api-link {
color: var(--primary-color);
cursor: pointer;
text-decoration: none;
}
.about-api-link:hover {

View File

@@ -85,14 +85,16 @@
</div>
<div class="article-member-avatars-container">
<NuxtLink
v-for="member in article.members"
:key="`${article.id}-${member.id}`"
class="article-member-avatar-item"
:to="`/users/${member.id}`"
>
<BaseImage class="article-member-avatar-item-img" :src="member.avatar" alt="avatar" />
</NuxtLink>
<div v-for="member in article.members" class="article-member-avatar-item">
<BaseUserAvatar
class="article-member-avatar-item-img"
:src="member.avatar"
:user-id="member.id"
alt="avatar"
:disable-link="true"
:width="25"
/>
</div>
</div>
<div class="article-comments main-info-text">
@@ -138,6 +140,7 @@ import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import { getToken } from '~/utils/auth'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import TimeManager from '~/utils/time'
import { selectedCategoryGlobal, selectedTagsGlobal } from '~/composables/postFilter'
useHead({
@@ -383,7 +386,6 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
selectedCategoryGlobal.value = newCategory
selectedTagsGlobal.value = newTags
})
</script>
<style scoped>
@@ -628,14 +630,12 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
margin-left: 20px;
}
.article-member-avatar-item {
width: 25px;
height: 25px;
border-radius: 50%;
overflow: hidden;
.article-member-avatar-item-img {
width: 100%;
height: 100%;
}
.article-member-avatar-item-img {
.article-member-avatar-item-img :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
@@ -692,6 +692,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
margin-left: 0px;
gap: 0px;
}
.article-main-container,
.header-item.main-item {
width: calc(70% - 20px);

View File

@@ -16,7 +16,7 @@
<div v-else class="login-page-button-primary disabled">
<div class="login-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
登录中...
</div>
</div>

View File

@@ -44,7 +44,12 @@
<div v-if="item.replyTo" class="reply-preview info-content-text">
<div class="reply-header">
<next class="reply-icon" />
<BaseImage class="reply-avatar" :src="item.replyTo.sender.avatar" alt="avatar" />
<BaseUserAvatar
class="reply-avatar"
:src="item.replyTo.sender.avatar"
:user-id="item.replyTo.sender.id"
:alt="item.replyTo.sender.username"
/>
<div class="reply-author">{{ item.replyTo.sender.username }}:</div>
</div>
<div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div>
@@ -121,6 +126,7 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const config = useRuntimeConfig()
const route = useRoute()
@@ -243,6 +249,7 @@ async function fetchMessages(page = 0) {
const newMessages = pageData.content.reverse().map((item) => ({
...item,
src: item.sender.avatar,
userId: item.sender.id,
iconClick: () => {
openUser(item.sender.id)
},
@@ -328,6 +335,7 @@ async function sendMessage(content, clearInput) {
messages.value.push({
...newMessage,
src: newMessage.sender.avatar,
userId: newMessage.sender.id,
iconClick: () => {
openUser(newMessage.sender.id)
},
@@ -403,6 +411,7 @@ const subscribeToConversation = () => {
messages.value.push({
...parsedMessage,
src: parsedMessage.sender.avatar,
userId: parsedMessage.sender.id,
iconClick: () => openUser(parsedMessage.sender.id),
})
@@ -686,6 +695,12 @@ function goBack() {
margin-right: 5px;
}
.reply-avatar :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.reply-preview {
margin-top: 10px;
padding: 10px;

View File

@@ -33,11 +33,12 @@
@click="goToConversation(convo.id)"
>
<div class="conversation-avatar">
<BaseImage
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
<BaseUserAvatar
:src="getOtherParticipant(convo)?.avatar"
:user-id="getOtherParticipant(convo)?.id"
:alt="getOtherParticipant(convo)?.username || '用户'"
class="avatar-img"
@error="handleAvatarError"
:disable-link="true"
/>
</div>
@@ -130,6 +131,7 @@ import { stripMarkdownLength } from '~/utils/markdown'
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
const config = useRuntimeConfig()
const conversations = ref([])
@@ -431,6 +433,11 @@ function minimize() {
width: 40px;
height: 40px;
border-radius: 50%;
}
.avatar-img :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -30,7 +30,9 @@
>
发布
</div>
<div v-else class="post-submit-loading"><loading-four /> 发布中...</div>
<div v-else class="post-submit-loading">
<loading-four class="loading-icon" /> 发布中...
</div>
</div>
</div>
<LotteryForm v-if="postType === 'LOTTERY'" :data="lottery" />

View File

@@ -184,6 +184,16 @@
}}</NuxtLink>
参与获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'ADMIN_GRANT' && item.fromUserId">
管理员
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
赠送了 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'ADMIN_GRANT'">
管理员赠送了 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<paper-money-two /> 你目前的积分是 {{ item.balance }}
</div>
@@ -229,6 +239,7 @@ const pointRules = [
'评论被点赞:每次 10 积分',
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
'文章被收录至精选:每次 500 积分',
'管理员赠送:特殊活动可由管理员手动赠送积分',
]
const goods = ref([])
@@ -250,6 +261,7 @@ const iconMap = {
LOTTERY_REWARD: 'fireworks',
POST_LIKE_CANCELLED: 'clear-icon',
COMMENT_LIKE_CANCELLED: 'clear-icon',
ADMIN_GRANT: 'paper-money-two',
}
const loadTrend = async () => {

View File

@@ -26,7 +26,9 @@
>
更新
</div>
<div v-else class="post-submit-loading"><loading-four /> 更新中...</div>
<div v-else class="post-submit-loading">
<loading-four class="loading-icon" /> 更新中...
</div>
</div>
</div>
</div>

View File

@@ -48,7 +48,13 @@
<div class="info-content-container author-info-container">
<div class="user-avatar-container" @click="gotoProfile">
<div class="user-avatar-item">
<BaseImage class="user-avatar-item-img" :src="author.avatar" alt="avatar" />
<BaseUserAvatar
class="user-avatar-item-img"
:src="author.avatar"
:user-id="author.id"
alt="avatar"
:disable-link="true"
/>
</div>
<div v-if="isMobile" class="info-content-header">
<div class="user-name">
@@ -193,6 +199,7 @@ import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { toast } from '~/main'
@@ -340,7 +347,7 @@ const mapComment = (
iconClick: () => navigateTo(`/users/${c.author.id}`),
parentUserName: parentUserName,
parentUserAvatar: parentUserAvatar,
parentUserClick: parentUserId ? () => navigateTo(`/users/${parentUserId}`) : null,
parentUserId: parentUserId,
})
const changeLogIcon = (l) => {
@@ -1186,6 +1193,12 @@ onMounted(async () => {
border-radius: 50%;
}
.user-avatar-item-img :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.info-content {
display: flex;
flex-direction: column;

View File

@@ -15,7 +15,13 @@
<div class="avatar-row">
<!-- label 充当点击区域内部隐藏 input -->
<label class="avatar-container">
<BaseImage :src="avatar" class="avatar-preview" alt="avatar" />
<BaseUserAvatar
:src="avatar"
:user-id="userId"
alt="avatar"
class="avatar-preview"
:disable-link="true"
/>
<!-- 半透明蒙层hover 时出现 -->
<div class="avatar-overlay">更换头像</div>
<input type="file" class="avatar-input" accept="image/*" @change="onAvatarChange" />
@@ -59,6 +65,35 @@
<div class="setting-title">注册模式</div>
<Dropdown v-model="registerMode" :fetch-options="fetchRegisterModes" />
</div>
<div class="form-row grant-row">
<div class="setting-title">发放积分</div>
<div class="grant-form">
<BaseInput
v-model="grantUsername"
placeholder="请输入用户名"
class="grant-input"
@input="grantError = ''"
/>
<BaseInput
v-model="grantAmount"
type="number"
placeholder="积分数量"
class="grant-input amount"
@input="grantError = ''"
/>
<button
type="button"
class="grant-button"
:class="{ disabled: isGrantingPoints }"
:disabled="isGrantingPoints"
@click="grantPoint"
>
{{ isGrantingPoints ? '发放中...' : '发放' }}
</button>
</div>
<div v-if="grantError" class="grant-error-message">{{ grantError }}</div>
<div class="setting-description">积分会立即发放给目标用户并记录在积分历史中</div>
</div>
</div>
<div class="buttons">
<div v-if="isSaving" class="save-button disabled">保存中...</div>
@@ -74,6 +109,7 @@ import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import BaseSwitch from '~/components/BaseSwitch.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
import { frostedState, setFrosted } from '~/utils/frosted'
@@ -87,6 +123,7 @@ const avatarFile = ref(null)
const tempAvatar = ref('')
const showCropper = ref(false)
const role = ref('')
const userId = ref(null)
const publishMode = ref('DIRECT')
const passwordStrength = ref('LOW')
const aiFormatLimit = ref(3)
@@ -94,6 +131,10 @@ const registerMode = ref('DIRECT')
const isLoadingPage = ref(false)
const isSaving = ref(false)
const frosted = ref(true)
const grantUsername = ref('')
const grantAmount = ref('')
const grantError = ref('')
const isGrantingPoints = ref(false)
onMounted(async () => {
isLoadingPage.value = true
@@ -103,6 +144,7 @@ onMounted(async () => {
username.value = user.username
introduction.value = user.introduction || ''
avatar.value = user.avatar
userId.value = user.id
role.value = user.role
if (role.value === 'ADMIN') {
loadAdminConfig()
@@ -175,6 +217,55 @@ const loadAdminConfig = async () => {
// ignore
}
}
const grantPoint = async () => {
if (isGrantingPoints.value) return
const username = grantUsername.value.trim()
if (!username) {
grantError.value = '用户名不能为空'
toast.error(grantError.value)
return
}
const amount = Number(grantAmount.value)
if (!Number.isInteger(amount) || amount <= 0) {
grantError.value = '积分数量必须为正整数'
toast.error(grantError.value)
return
}
isGrantingPoints.value = true
try {
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/points/grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ username, amount }),
})
let data = null
try {
data = await res.json()
} catch (e) {
// ignore body parse errors
}
if (res.ok) {
toast.success(`已为 ${username} 发放 ${amount} 积分`)
grantUsername.value = ''
grantAmount.value = ''
grantError.value = ''
} else {
const message = data?.error || '发放失败'
grantError.value = message
toast.error(message)
}
} catch (e) {
grantError.value = '发放失败,请稍后再试'
toast.error(grantError.value)
} finally {
isGrantingPoints.value = false
}
}
const save = async () => {
isSaving.value = true
@@ -271,6 +362,11 @@ const save = async () => {
width: 80px;
height: 80px;
border-radius: 40px;
}
.avatar-preview :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -309,6 +405,51 @@ const save = async () => {
max-width: 200px;
}
.grant-row {
max-width: 100%;
}
.grant-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.grant-input {
flex: 1 1 180px;
}
.grant-input.amount {
max-width: 140px;
}
.grant-button {
background-color: var(--primary-color);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 16px;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
.grant-button.disabled,
.grant-button:disabled {
cursor: not-allowed;
background-color: var(--primary-color-disabled);
}
.grant-button:not(.disabled):hover {
background-color: var(--primary-color-hover);
}
.grant-error-message {
color: red;
font-size: 14px;
margin-top: 8px;
}
.switch-row {
flex-direction: row;
align-items: center;

View File

@@ -35,7 +35,7 @@
</div>
<div v-else class="signup-page-button-primary disabled">
<div class="signup-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
发送中...
</div>
</div>
@@ -56,7 +56,7 @@
</div>
<div v-else class="signup-page-button-primary disabled">
<div class="signup-page-button-text">
<loading-four />
<loading-four class="loading-icon" />
验证中...
</div>
</div>

View File

@@ -7,7 +7,12 @@
<div v-else>
<div class="profile-page-header">
<div class="profile-page-header-avatar">
<BaseImage :src="user.avatar" alt="avatar" class="profile-page-header-avatar-img" />
<BaseUserAvatar
:src="user.avatar"
:user-id="user.id"
alt="avatar"
class="profile-page-header-avatar-img"
/>
</div>
<div class="profile-page-header-user-info">
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
@@ -112,19 +117,18 @@
{{ item.comment.post.title }}
</NuxtLink>
<template v-if="item.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
回复了
<next class="reply-icon" /> 回复了
</template>
<template v-else> 下评论了 </template>
<NuxtLink
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
class="timeline-comment-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
@@ -143,15 +147,7 @@
<div class="summary-content" v-if="hotPosts.length > 0">
<BaseTimeline :items="hotPosts">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
<div class="timeline-date">
{{ formatDate(item.post.createdAt) }}
</div>
<TimelinePostItem :item="item" />
</template>
</BaseTimeline>
</div>
@@ -164,15 +160,7 @@
<div class="summary-content" v-if="hotTags.length > 0">
<BaseTimeline :items="hotTags">
<template #item="{ item }">
<span class="timeline-link" @click="gotoTag(item.tag)">
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
</span>
<div class="timeline-snippet" v-if="item.tag.description">
{{ item.tag.description }}
</div>
<div class="timeline-date">
{{ formatDate(item.tag.createdAt) }}
</div>
<TimelineTagItem :item="item" />
</template>
</BaseTimeline>
</div>
@@ -212,18 +200,17 @@
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }">
<ProfileTimelinePostItem v-if="item.type === 'post'" :item="item" />
<ProfileTimelineCommentGroup v-else-if="item.type === 'comment'" :item="item" />
<ProfileTimelineCommentGroup v-else-if="item.type === 'reply'" :item="item" />
<template v-if="item.type === 'post'">
<TimelinePostItem :item="item" />
</template>
<template v-else-if="item.type === 'comment'">
<TimelineCommentGroup :item="item" />
</template>
<template v-else-if="item.type === 'reply'">
<TimelineCommentGroup :item="item" />
</template>
<template v-else-if="item.type === 'tag'">
创建了标签
<span class="timeline-link" @click="gotoTag(item.tag)">
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
</span>
<div class="timeline-snippet" v-if="item.tag.description">
{{ item.tag.description }}
</div>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
<TimelineTagItem :item="item" />
</template>
</template>
</BaseTimeline>
@@ -287,8 +274,10 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProfileTimelineCommentGroup from '~/components/ProfileTimelineCommentGroup.vue'
import ProfileTimelinePostItem from '~/components/ProfileTimelinePostItem.vue'
import TimelineCommentGroup from '~/components/TimelineCommentGroup.vue'
import TimelinePostItem from '~/components/TimelinePostItem.vue'
import TimelineTagItem from '~/components/TimelineTagItem.vue'
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
@@ -378,7 +367,12 @@ const fetchSummary = async () => {
const postsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-posts`)
if (postsRes.ok) {
const data = await postsRes.json()
hotPosts.value = data.map((p) => ({ icon: 'file-text', post: p }))
hotPosts.value = data.map((p) => ({
icon: 'file-text',
type: 'post',
post: p,
createdAt: p.createdAt,
}))
}
const repliesRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-replies`)
@@ -390,25 +384,65 @@ const fetchSummary = async () => {
const tagsRes = await fetch(`${API_BASE_URL}/api/users/${username}/hot-tags`)
if (tagsRes.ok) {
const data = await tagsRes.json()
hotTags.value = data.map((t) => ({ icon: 'tag-one', tag: t }))
hotTags.value = data.map((t) => ({
icon: 'tag-one',
type: 'tag',
tag: t,
createdAt: t.createdAt,
}))
}
}
const isSameDay = (a, b) => {
const dateA = new Date(a)
const dateB = new Date(b)
return (
dateA.getFullYear() === dateB.getFullYear() &&
dateA.getMonth() === dateB.getMonth() &&
dateA.getDate() === dateB.getDate()
)
const isDiscussionItem = (item) => item && (item.type === 'comment' || item.type === 'reply')
const toDateKey = (value) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
const createCommentEntry = (item) => ({
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
})
const combineDiscussionItems = (items) => {
const result = []
items.forEach((item) => {
if (!isDiscussionItem(item)) {
result.push(item)
return
}
const dateKey = toDateKey(item.createdAt)
const last = result[result.length - 1]
if (last && isDiscussionItem(last) && last.dateKey === dateKey) {
last.entries.push({
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
})
if (item.type === 'comment' && last.type === 'reply') {
last.type = 'comment'
}
if (new Date(item.createdAt) > new Date(last.createdAt)) {
last.createdAt = item.createdAt
}
} else {
result.push({
type: item.type,
icon: item.icon,
createdAt: item.createdAt,
dateKey,
entries: [
{
type: item.type,
comment: item.comment,
createdAt: item.createdAt,
},
],
})
}
})
return result
}
const fetchTimeline = async () => {
const [postsRes, repliesRes, tagsRes] = await Promise.all([
@@ -440,32 +474,7 @@ const fetchTimeline = async () => {
})),
]
mapped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
const grouped = []
for (const item of mapped) {
if (item.type === 'comment') {
const last = grouped[grouped.length - 1]
if (last && last.type === 'comment' && isSameDay(last.createdAt, item.createdAt)) {
last.entries.push(createCommentEntry(item))
if (new Date(item.createdAt) > new Date(last.createdAt)) {
last.createdAt = item.createdAt
}
} else {
grouped.push({
...item,
entries: [createCommentEntry(item)],
})
}
} else if (item.type === 'reply') {
grouped.push({
...item,
entries: [createCommentEntry(item)],
})
} else {
grouped.push(item)
}
}
timelineItems.value = grouped
timelineItems.value = combineDiscussionItems(mapped)
}
const fetchFollowUsers = async () => {
@@ -649,6 +658,11 @@ watch(selectedTab, async (val) => {
width: 200px;
height: 200px;
border-radius: 50%;
}
.profile-page-header-avatar-img :deep(.base-user-avatar-img) {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -666,6 +680,11 @@ watch(selectedTab, async (val) => {
color: #666;
}
.reply-icon {
color: var(--primary-color);
margin-left: 5px;
}
.profile-page-header-user-info-buttons {
display: flex;
flex-direction: row;
@@ -918,8 +937,8 @@ watch(selectedTab, async (val) => {
.timeline-link {
font-weight: bold;
color: var(--primary-color);
text-decoration: none;
color: var(--text-color);
word-break: break-word;
}
@@ -956,9 +975,25 @@ watch(selectedTab, async (val) => {
justify-content: space-between;
}
.timeline-title {
font-size: 18px;
font-weight: bold;
.tags-container {
display: flex;
flex-direction: row;
gap: 10px;
padding-top: 5px;
justify-content: space-between;
align-items: center;
}
.tags-container-item {
display: flex;
flex-direction: row;
gap: 5px;
align-items: center;
}
.timeline-tag-title {
font-size: 16px;
font-weight: 600;
}
.comment-content {
@@ -994,6 +1029,7 @@ watch(selectedTab, async (val) => {
color: var(--text-color);
word-break: break-word;
text-decoration: underline;
margin-left: 5px;
}
.timeline-comment-link:hover {
@@ -1055,6 +1091,7 @@ watch(selectedTab, async (val) => {
.profile-page-header-avatar-img {
width: 100px;
height: 100px;
border-radius: 50%;
}
:deep(.base-tabs-item) {

View File

@@ -157,6 +157,7 @@ const SANITIZE_CFG = {
'th',
'video',
'source',
'iframe',
],
// 允许的属性
allowedAttributes: {
@@ -180,6 +181,16 @@ const SANITIZE_CFG = {
'crossorigin',
],
source: ['src', 'type'],
iframe: [
'src',
'title',
'width',
'height',
'allow',
'allowfullscreen',
'frameborder',
'referrerpolicy',
],
},
// 允许的类名(保留你的样式钩子)
allowedClasses: {

View File

@@ -40,4 +40,33 @@ export default class TimeManager {
return `${date.getFullYear()}.${month}.${day} ${timePart}`
}
// 仅显示日期(不含时间)
static formatWithDay(input) {
const date = new Date(input)
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
if (diffDays === 0) return '今天'
if (diffDays === 1) return '昨天'
if (diffDays === 2) return '前天'
const month = date.getMonth() + 1
const day = date.getDate()
if (date.getFullYear() === now.getFullYear()) {
return `${month}.${day}`
}
if (date.getFullYear() === now.getFullYear() - 1) {
return `去年 ${month}.${day}`
}
return `${date.getFullYear()}.${month}.${day}`
}
}

Some files were not shown because too many files have changed in this diff Show More