Compare commits

...

65 Commits

Author SHA1 Message Date
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
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
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
Tim
b6c2471bc3 Enhance user timeline grouping and post metadata 2025-09-19 00:22:34 +08:00
69 changed files with 2575 additions and 298 deletions

View File

@@ -132,6 +132,19 @@
<artifactId>springdoc-openapi-starter-webmvc-api</artifactId>
<version>2.2.0</version>
</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

@@ -101,8 +101,8 @@ public class SecurityConfig {
"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",

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

@@ -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

@@ -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

@@ -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

@@ -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=${SEARCH_HOST:localhost}
app.search.port=${SEARCH_PORT:9200}
app.search.scheme=${SEARCH_SCHEME:http}
app.search.username=${SEARCH_USERNAME:}
app.search.password=${SEARCH_PASSWORD:}
app.search.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:}

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

@@ -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();

View File

@@ -1,6 +1,11 @@
# 前端访问端口
SERVER_PORT=8080
# OpenSearch 配置
OPENSEARCH_PORT=9200
OPENSEARCH_METRICS_PORT=9600
OPENSEARCH_DASHBOARDS_PORT=5601
# MySQL 配置
MYSQL_ROOT_PASSWORD=toor

9
docker/DockerFile Normal file
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

@@ -14,6 +14,44 @@ services:
- ../backend/src/main/resources/db/init:/docker-entrypoint-initdb.d
networks:
- openisle-network
# OpenSearch Service
opensearch:
build:
context: .
dockerfile: Dockerfile
container_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:
- ./data:/usr/share/opensearch/data
- ./snapshots:/snapshots
ports:
- "${OPENSEARCH_PORT:-9200}:9200"
- "${OPENSEARCH_METRICS_PORT:-9600}:9600"
restart: unless-stopped
dashboards:
image: opensearchproject/opensearch-dashboards:3.0.0
container_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
# Java spring boot service
springboot:

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

@@ -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

@@ -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,

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

@@ -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

@@ -9,23 +9,6 @@
<div class="comment-content-item-main">
<comment-one class="comment-content-item-icon" />
<div class="comment-content-item-text">
<span class="comment-content-item-prefix">
<NuxtLink :to="`/posts/${entry.comment.post.id}`" class="timeline-link">
{{ entry.comment.post.title }}
</NuxtLink>
<template v-if="entry.comment.parentComment">
下对
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.parentComment.id}`"
class="timeline-link"
>
{{ parentSnippet(entry) }}
</NuxtLink>
回复了
</template>
<template v-else> 下评论了 </template>
</span>
<NuxtLink
:to="`/posts/${entry.comment.post.id}#comment-${entry.comment.id}`"
class="timeline-comment-link"
@@ -65,7 +48,7 @@ const entries = computed(() => {
return []
})
const formattedDate = computed(() => TimeManager.format(props.item.createdAt))
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))
@@ -93,9 +76,8 @@ const parentSnippet = (entry) =>
display: flex;
flex-direction: column;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
padding-top: 5px;
padding-bottom: 20px;
}
.timeline-header {
@@ -112,20 +94,20 @@ const parentSnippet = (entry) =>
.timeline-date {
font-size: 12px;
color: var(--timeline-date-color, #888);
white-space: nowrap;
}
.comment-content {
display: flex;
flex-direction: column;
gap: 16px;
gap: 3px;
}
.comment-content-item {
display: flex;
flex-direction: column;
flex-direction: row;
justify-content: space-between;
gap: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--comment-item-border, rgba(0, 0, 0, 0.05));
}
.comment-content-item:last-child {
@@ -160,11 +142,11 @@ const parentSnippet = (entry) =>
.timeline-comment-link {
font-size: 14px;
color: var(--link-color);
text-decoration: none;
text-decoration: underline;
}
.timeline-comment-link:hover {
text-decoration: underline;
color: var(--primary-color);
}
.timeline-link {

View File

@@ -13,11 +13,7 @@
</div>
<div class="article-meta" v-if="hasMeta">
<ArticleCategory v-if="item.post?.category" :category="item.post.category" />
<div class="article-tags" v-if="(item.post?.tags?.length ?? 0) > 0">
<span class="article-tag" v-for="tag in item.post?.tags" :key="tag.id || tag.name">
#{{ tag.name }}
</span>
</div>
<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>
@@ -29,7 +25,6 @@
<script setup>
import { computed } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import { stripMarkdown } from '~/utils/markdown'
import TimeManager from '~/utils/time'
@@ -58,8 +53,8 @@ const hasMeta = computed(() => {
.timeline-container {
display: flex;
flex-direction: column;
padding-top: 5px;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: var(--timeline-card-background, transparent);
}
@@ -83,6 +78,9 @@ const hasMeta = computed(() => {
.article-container {
display: flex;
flex-direction: column;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
padding: 10px;
gap: 6px;
}

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

@@ -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>
@@ -233,6 +235,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

@@ -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" />
@@ -74,6 +80,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 +94,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)
@@ -103,6 +111,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()
@@ -271,6 +280,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;
}

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,13 +200,6 @@
<div class="timeline-list">
<BaseTimeline :items="filteredTimelineItems">
<template #item="{ item }">
<!-- <template v-if="item.type === 'post'">
发布了文章
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template> -->
<template v-if="item.type === 'post'">
<TimelinePostItem :item="item" />
</template>
@@ -229,14 +210,7 @@
<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>
@@ -302,6 +276,8 @@ import BaseTabs from '~/components/BaseTabs.vue'
import LevelProgress from '~/components/LevelProgress.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'
@@ -391,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`)
@@ -403,7 +384,12 @@ 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,
}))
}
}
@@ -672,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;
}
@@ -689,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;
@@ -941,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;
}
@@ -979,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 {
@@ -1017,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 {
@@ -1078,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

@@ -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}`
}
}