mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-06 12:00:44 +08:00
fix: 后端highlight
This commit is contained in:
@@ -115,6 +115,9 @@ public class SearchController {
|
|||||||
dto.setSubText(r.subText());
|
dto.setSubText(r.subText());
|
||||||
dto.setExtra(r.extra());
|
dto.setExtra(r.extra());
|
||||||
dto.setPostId(r.postId());
|
dto.setPostId(r.postId());
|
||||||
|
dto.setHighlightedText(r.highlightedText());
|
||||||
|
dto.setHighlightedSubText(r.highlightedSubText());
|
||||||
|
dto.setHighlightedExtra(r.highlightedExtra());
|
||||||
return dto;
|
return dto;
|
||||||
})
|
})
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|||||||
@@ -12,4 +12,7 @@ public class SearchResultDto {
|
|||||||
private String subText;
|
private String subText;
|
||||||
private String extra;
|
private String extra;
|
||||||
private Long postId;
|
private Long postId;
|
||||||
|
private String highlightedText;
|
||||||
|
private String highlightedSubText;
|
||||||
|
private String highlightedExtra;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -28,6 +30,7 @@ import org.opensearch.client.opensearch._types.query_dsl.TextQueryType;
|
|||||||
import org.opensearch.client.opensearch.core.SearchResponse;
|
import org.opensearch.client.opensearch.core.SearchResponse;
|
||||||
import org.opensearch.client.opensearch.core.search.Hit;
|
import org.opensearch.client.opensearch.core.search.Hit;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.util.HtmlUtils;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -97,49 +100,96 @@ public class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<SearchResult> fallbackGlobalSearch(String keyword) {
|
private List<SearchResult> fallbackGlobalSearch(String keyword) {
|
||||||
|
final String effectiveKeyword = keyword == null ? "" : keyword.trim();
|
||||||
Stream<SearchResult> users = searchUsers(keyword)
|
Stream<SearchResult> users = searchUsers(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(u ->
|
.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<SearchResult> categories = searchCategories(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(c ->
|
.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<SearchResult> tags = searchTags(keyword)
|
||||||
.stream()
|
.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
|
// Merge post results while removing duplicates between search by content
|
||||||
// and search by title
|
// and search by title
|
||||||
List<SearchResult> mergedPosts = Stream.concat(
|
List<SearchResult> mergedPosts = Stream.concat(
|
||||||
searchPosts(keyword)
|
searchPosts(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(p ->
|
.map(p -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(p.getContent(), keyword, false);
|
||||||
|
return new SearchResult(
|
||||||
"post",
|
"post",
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getTitle(),
|
p.getTitle(),
|
||||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
extractSnippet(p.getContent(), keyword, false),
|
snippet,
|
||||||
null
|
null,
|
||||||
)
|
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||||
),
|
highlightHtml(
|
||||||
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
|
effectiveKeyword
|
||||||
|
),
|
||||||
|
highlightHtml(snippet, effectiveKeyword)
|
||||||
|
);
|
||||||
|
}),
|
||||||
searchPostsByTitle(keyword)
|
searchPostsByTitle(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(p ->
|
.map(p -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(p.getContent(), keyword, true);
|
||||||
|
return new SearchResult(
|
||||||
"post_title",
|
"post_title",
|
||||||
p.getId(),
|
p.getId(),
|
||||||
p.getTitle(),
|
p.getTitle(),
|
||||||
p.getCategory() != null ? p.getCategory().getName() : null,
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
extractSnippet(p.getContent(), keyword, true),
|
snippet,
|
||||||
null
|
null,
|
||||||
)
|
highlightHtml(p.getTitle(), effectiveKeyword),
|
||||||
)
|
highlightHtml(
|
||||||
|
p.getCategory() != null ? p.getCategory().getName() : null,
|
||||||
|
effectiveKeyword
|
||||||
|
),
|
||||||
|
highlightHtml(snippet, effectiveKeyword)
|
||||||
|
);
|
||||||
|
})
|
||||||
)
|
)
|
||||||
.collect(
|
.collect(
|
||||||
java.util.stream.Collectors.toMap(
|
java.util.stream.Collectors.toMap(
|
||||||
@@ -155,16 +205,20 @@ public class SearchService {
|
|||||||
|
|
||||||
Stream<SearchResult> comments = searchComments(keyword)
|
Stream<SearchResult> comments = searchComments(keyword)
|
||||||
.stream()
|
.stream()
|
||||||
.map(c ->
|
.map(c -> {
|
||||||
new SearchResult(
|
String snippet = extractSnippet(c.getContent(), keyword, false);
|
||||||
|
return new SearchResult(
|
||||||
"comment",
|
"comment",
|
||||||
c.getId(),
|
c.getId(),
|
||||||
c.getPost().getTitle(),
|
c.getPost().getTitle(),
|
||||||
c.getAuthor().getUsername(),
|
c.getAuthor().getUsername(),
|
||||||
extractSnippet(c.getContent(), keyword, false),
|
snippet,
|
||||||
c.getPost().getId()
|
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)
|
return Stream.of(users, categories, tags, mergedPosts.stream(), comments)
|
||||||
.flatMap(s -> s)
|
.flatMap(s -> s)
|
||||||
@@ -379,15 +433,23 @@ public class SearchService {
|
|||||||
if ("post".equals(documentType) && highlightTitle) {
|
if ("post".equals(documentType) && highlightTitle) {
|
||||||
effectiveType = "post_title";
|
effectiveType = "post_title";
|
||||||
}
|
}
|
||||||
String snippet = highlightedContent != null && !highlightedContent.isBlank()
|
String snippetHtml = highlightedContent != null && !highlightedContent.isBlank()
|
||||||
? cleanHighlight(highlightedContent)
|
? highlightedContent
|
||||||
: null;
|
: null;
|
||||||
if (snippet == null && highlightTitle) {
|
if (snippetHtml == null && highlightTitle) {
|
||||||
snippet = cleanHighlight(highlightedTitle);
|
snippetHtml = highlightedTitle;
|
||||||
}
|
}
|
||||||
|
String snippet = snippetHtml != null && !snippetHtml.isBlank()
|
||||||
|
? cleanHighlight(snippetHtml)
|
||||||
|
: null;
|
||||||
boolean fromStart = "post_title".equals(effectiveType);
|
boolean fromStart = "post_title".equals(effectiveType);
|
||||||
if (snippet == null || snippet.isBlank()) {
|
if (snippet == null || snippet.isBlank()) {
|
||||||
snippet = fallbackSnippet(document.content(), keyword, fromStart);
|
snippet = fallbackSnippet(document.content(), keyword, fromStart);
|
||||||
|
if (snippetHtml == null) {
|
||||||
|
snippetHtml = highlightHtml(snippet, keyword);
|
||||||
|
}
|
||||||
|
} else if (snippetHtml == null) {
|
||||||
|
snippetHtml = highlightHtml(snippet, keyword);
|
||||||
}
|
}
|
||||||
if (snippet == null) {
|
if (snippet == null) {
|
||||||
snippet = "";
|
snippet = "";
|
||||||
@@ -400,13 +462,21 @@ public class SearchService {
|
|||||||
subText = document.author();
|
subText = document.author();
|
||||||
postId = document.postId();
|
postId = document.postId();
|
||||||
}
|
}
|
||||||
|
String highlightedText = highlightTitle
|
||||||
|
? highlightedTitle
|
||||||
|
: highlightHtml(document.title(), keyword);
|
||||||
|
String highlightedSubText = highlightHtml(subText, keyword);
|
||||||
|
String highlightedExtra = snippetHtml != null ? snippetHtml : highlightHtml(snippet, keyword);
|
||||||
return new SearchResult(
|
return new SearchResult(
|
||||||
effectiveType,
|
effectiveType,
|
||||||
document.entityId(),
|
document.entityId(),
|
||||||
document.title(),
|
document.title(),
|
||||||
subText,
|
subText,
|
||||||
snippet,
|
snippet,
|
||||||
postId
|
postId,
|
||||||
|
highlightedText,
|
||||||
|
highlightedSubText,
|
||||||
|
highlightedExtra
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,12 +532,45 @@ public class SearchService {
|
|||||||
return snippet;
|
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(
|
public record SearchResult(
|
||||||
String type,
|
String type,
|
||||||
Long id,
|
Long id,
|
||||||
String text,
|
String text,
|
||||||
String subText,
|
String subText,
|
||||||
String extra,
|
String extra,
|
||||||
Long postId
|
Long postId,
|
||||||
|
String highlightedText,
|
||||||
|
String highlightedSubText,
|
||||||
|
String highlightedExtra
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ class SearchControllerTest {
|
|||||||
c.setContent("nice");
|
c.setContent("nice");
|
||||||
Mockito.when(searchService.globalSearch("n")).thenReturn(
|
Mockito.when(searchService.globalSearch("n")).thenReturn(
|
||||||
List.of(
|
List.of(
|
||||||
new SearchService.SearchResult("user", 1L, "bob", null, null, null),
|
new SearchService.SearchResult("user", 1L, "bob", null, null, null, null, null, null),
|
||||||
new SearchService.SearchResult("post", 2L, "hello", null, null, null),
|
new SearchService.SearchResult("post", 2L, "hello", null, null, null, null, null, null),
|
||||||
new SearchService.SearchResult("comment", 3L, "nice", null, null, null)
|
new SearchService.SearchResult("comment", 3L, "nice", null, null, null, null, null, null)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,20 @@
|
|||||||
<div class="search-option-item">
|
<div class="search-option-item">
|
||||||
<component :is="iconMap[option.type]" class="result-icon" />
|
<component :is="iconMap[option.type]" class="result-icon" />
|
||||||
<div class="result-body">
|
<div class="result-body">
|
||||||
<div class="result-main" v-html="highlight(option.text)"></div>
|
<div
|
||||||
<div v-if="option.subText" class="result-sub" v-html="highlight(option.subText)"></div>
|
class="result-main"
|
||||||
<div v-if="option.extra" class="result-extra" v-html="highlight(option.extra)"></div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,16 +81,30 @@ const fetchResults = async (kw) => {
|
|||||||
subText: r.subText,
|
subText: r.subText,
|
||||||
extra: r.extra,
|
extra: r.extra,
|
||||||
postId: r.postId,
|
postId: r.postId,
|
||||||
|
highlightedText: r.highlightedText,
|
||||||
|
highlightedSubText: r.highlightedSubText,
|
||||||
|
highlightedExtra: r.highlightedExtra,
|
||||||
}))
|
}))
|
||||||
return results.value
|
return results.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = (text) => {
|
const escapeHtml = (value = '') =>
|
||||||
text = stripMarkdown(text)
|
String(value)
|
||||||
if (!keyword.value) return text
|
.replace(/&/g, '&')
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
.replace(/</g, '<')
|
||||||
const res = text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
.replace(/>/g, '>')
|
||||||
return res
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const renderHighlight = (highlighted, fallback) => {
|
||||||
|
if (highlighted) {
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
const plain = stripMarkdown(fallback || '')
|
||||||
|
if (!plain) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return escapeHtml(plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
@@ -168,7 +193,7 @@ defineExpose({
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.highlight) {
|
:deep(mark) {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,14 @@
|
|||||||
:disable-link="true"
|
:disable-link="true"
|
||||||
/>
|
/>
|
||||||
<div class="result-body">
|
<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
|
<div
|
||||||
v-if="option.introduction"
|
v-if="option.introduction"
|
||||||
class="result-sub"
|
class="result-sub"
|
||||||
v-html="highlight(option.introduction)"
|
v-html="renderHighlight(option.highlightedIntroduction, option.introduction)"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,15 +82,29 @@ const fetchResults = async (kw) => {
|
|||||||
username: u.username,
|
username: u.username,
|
||||||
avatar: u.avatar,
|
avatar: u.avatar,
|
||||||
introduction: u.introduction,
|
introduction: u.introduction,
|
||||||
|
highlightedUsername: u.highlightedText,
|
||||||
|
highlightedIntroduction: u.highlightedSubText || u.highlightedExtra,
|
||||||
}))
|
}))
|
||||||
return results.value
|
return results.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = (text) => {
|
const escapeHtml = (value = '') =>
|
||||||
text = stripMarkdown(text || '')
|
String(value)
|
||||||
if (!keyword.value) return text
|
.replace(/&/g, '&')
|
||||||
const reg = new RegExp(keyword.value, 'gi')
|
.replace(/</g, '<')
|
||||||
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
const renderHighlight = (highlighted, fallback) => {
|
||||||
|
if (highlighted) {
|
||||||
|
return highlighted
|
||||||
|
}
|
||||||
|
const plain = stripMarkdown(fallback || '')
|
||||||
|
if (!plain) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return escapeHtml(plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(selected, async (val) => {
|
watch(selected, async (val) => {
|
||||||
@@ -170,7 +187,7 @@ defineExpose({
|
|||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.highlight) {
|
:deep(mark) {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user