多关键词搜索与高亮优化,补充E2E测试与文档 后端各实体搜索接口支持空格分隔多关键词(词间AND、词内多字段OR,兼容全角空格),统一调用SearchWhereByKeys。前端搜索结果页高亮逻辑升级为多词逐词高亮,避免嵌套<mark>,兼容全角空格。新增Playwright E2E用例,验证多关键词搜索与高亮。功能文档同步补充相关说明,确保实现与文档一致。大石头 authored at 2026-09-02 21:30:48
diff --git "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md" "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
index aa46401..3911d21 100644
--- "a/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
+++ "b/Doc/\345\212\237\350\203\275\346\270\205\345\215\225.md"
@@ -52,8 +52,8 @@
| 编码 | 功能 | 类型 | 实现 | 测试 | 注释 | 说明 |
|------|------|:----:|:----:|:----:|:----:|------|
-| CONTENT-2 | 搜索接口 | 后端 | ✅ | ✅ | ✅ | Document.Search 多字段 LIKE |
-| WEB-4a | 搜索页-回显与高亮 | 前端 | ✅ | ✅ | ✅ | 关键词 mark 高亮(防 XSS) |
+| CONTENT-2 | 搜索接口 | 后端 | ✅ | ✅ | ✅ | Document.Search 多字段 LIKE(空格拆词词间 AND) |
+| WEB-4a | 搜索页-回显与高亮 | 前端 | ✅ | ✅ | ✅ | 关键词 mark 高亮(防 XSS,逐词) |
| WEB-4b | 搜索页-空态与分页 | 前端 | ✅ | ✅ | ✅ | 空结果提示 + 无页码路由 |
> 父需求状态:✅(全部完成)
diff --git a/E2ETest/playwright-report/index.html b/E2ETest/playwright-report/index.html
index b724cc3..188388a 100644
--- a/E2ETest/playwright-report/index.html
+++ b/E2ETest/playwright-report/index.html
@@ -46,4 +46,4 @@ Error generating stack: `+e.message+`
<div id='root'></div>
</body>
</html>
-<template id="playwrightReportBase64">data:application/zip;base64,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</template>
\ No newline at end of file
+<template id="playwrightReportBase64">data:application/zip;base64,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</template>
\ No newline at end of file
diff --git a/E2ETest/tests/search.spec.ts b/E2ETest/tests/search.spec.ts
index c082f67..6ad4df6 100644
--- a/E2ETest/tests/search.spec.ts
+++ b/E2ETest/tests/search.spec.ts
@@ -64,4 +64,27 @@ test.describe('搜索结果页', () => {
const titleMark = await page.locator('.sr-title mark').count();
expect(titleMark + descMark, '至少标题或摘要高亮').toBeGreaterThan(0);
});
+
+ test('空格多关键词:拆词 AND 页面正常、回显原文,标题摘要关键词有逐词高亮', async ({ page }) => {
+ // 空格分隔多词走 /search-词1%20词2;本地/部署后新版已是拆词 AND(词间 And、词内多字段 Or)
+ // 命中词可能仅在正文(摘要不显示),故仅当标题/摘要出现关键词时才要求有 <mark> 逐词高亮
+ const words = '代理 星尘';
+ const wordList = words.split(' ');
+ const errors = collectErrors(page);
+ const resp = await page.goto(`/search-${encodeURIComponent(words)}`);
+ expect(resp!.status(), `多关键词搜索页返回 ${resp!.status()}`).toBe(200);
+ await expect(page.locator('.search-hero input[name="key"]')).toHaveValue(words);
+
+ const n = await page.locator('.search-result-item').count();
+ if (n > 0) {
+ // 标题或摘要中是否出现了任一关键词(命中词可能只在正文)
+ const shown = await page.locator('.sr-title, .sr-desc').evaluateAll(
+ (els, ws) => els.some((el) => ws.some((w) => el.textContent!.includes(w))), wordList);
+ if (shown) {
+ const marks = await page.locator('.sr-title mark, .sr-desc mark').count();
+ expect(marks, '标题/摘要出现关键词时应逐词 <mark> 高亮').toBeGreaterThan(0);
+ }
+ }
+ expectNoErrors(errors);
+ });
});
diff --git "a/NewLife.YuqueWeb/Entity/Html\350\247\204\345\210\231.Biz.cs" "b/NewLife.YuqueWeb/Entity/Html\350\247\204\345\210\231.Biz.cs"
index 5587ac5..db8e980 100644
--- "a/NewLife.YuqueWeb/Entity/Html\350\247\204\345\210\231.Biz.cs"
+++ "b/NewLife.YuqueWeb/Entity/Html\350\247\204\345\210\231.Biz.cs"
@@ -169,7 +169,11 @@ namespace NewLife.YuqueWeb.Entity
if (kind >= 0) exp &= _.Kind == kind;
exp &= _.UpdateTime.Between(start, end);
- if (!key.IsNullOrEmpty()) exp &= _.Rule.Contains(key) | _.Target.Contains(key) | _.CreateUser.Contains(key) | _.CreateIP.Contains(key) | _.UpdateUser.Contains(key) | _.UpdateIP.Contains(key) | _.Remark.Contains(key);
+ if (!key.IsNullOrEmpty())
+ {
+ // 空格分隔的多关键词,词间 And、词内多字段 Or;兼容全角空格分隔
+ exp &= SearchWhereByKeys(key.Replace('\u3000', ' '), [_.Rule, _.Target, _.CreateUser, _.CreateIP, _.UpdateUser, _.UpdateIP, _.Remark]);
+ }
return FindAll(exp, page);
}
diff --git "a/NewLife.YuqueWeb/Entity/\345\257\274\350\210\252.Biz.cs" "b/NewLife.YuqueWeb/Entity/\345\257\274\350\210\252.Biz.cs"
index 4540ccb..dd10c17 100644
--- "a/NewLife.YuqueWeb/Entity/\345\257\274\350\210\252.Biz.cs"
+++ "b/NewLife.YuqueWeb/Entity/\345\257\274\350\210\252.Biz.cs"
@@ -129,7 +129,11 @@ namespace NewLife.YuqueWeb.Entity
if (!name.IsNullOrEmpty()) exp &= _.Name == name;
if (parentId >= 0) exp &= _.ParentId == parentId;
exp &= _.UpdateTime.Between(start, end);
- if (!key.IsNullOrEmpty()) exp &= _.Name.Contains(key) | _.Url.Contains(key) | _.CreateUser.Contains(key) | _.CreateIP.Contains(key) | _.UpdateUser.Contains(key) | _.UpdateIP.Contains(key) | _.Remark.Contains(key);
+ if (!key.IsNullOrEmpty())
+ {
+ // 空格分隔的多关键词,词间 And、词内多字段 Or;兼容全角空格分隔
+ exp &= SearchWhereByKeys(key.Replace('\u3000', ' '), [_.Name, _.Url, _.CreateUser, _.CreateIP, _.UpdateUser, _.UpdateIP, _.Remark]);
+ }
return FindAll(exp, page);
}
diff --git "a/NewLife.YuqueWeb/Entity/\346\226\207\346\241\243.Biz.cs" "b/NewLife.YuqueWeb/Entity/\346\226\207\346\241\243.Biz.cs"
index 133a900..a487d0d 100644
--- "a/NewLife.YuqueWeb/Entity/\346\226\207\346\241\243.Biz.cs"
+++ "b/NewLife.YuqueWeb/Entity/\346\226\207\346\241\243.Biz.cs"
@@ -171,17 +171,19 @@ public partial class Document : Entity<Document>
exp &= _.UpdateTime.Between(start, end);
if (!key.IsNullOrEmpty())
{
- // 标题/摘要/作者/备注等轻量字段始终参与搜索
+ // 空格分隔的多关键词,词间 And、词内多字段 Or(如 "代理 星尘" → 含"代理" And 含"星尘"),并兼容全角空格分隔
+ key = key.Replace('\u3000', ' ');
+
+ // 轻量字段(标题/摘要/作者/备注等)始终参与搜索
if (searchBody)
{
- // 小规模文章库:全文搜索(标题/摘要/正文 OR 关系)
- exp &= _.Code == key | _.Title.Contains(key) | _.Summary.Contains(key) | _.UserName.Contains(key) | _.Format == key | _.Remark.Contains(key)
- | _.Body.Contains(key) | _.BodyHtml.Contains(key) | _.Html.Contains(key);
+ // 小规模文章库:全文搜索(正文大字段仅在库小时参与,避免大表 LIKE 全表扫描)
+ exp &= SearchWhereByKeys(key, [_.Code, _.Title, _.Summary, _.UserName, _.Format, _.Remark, _.Body, _.BodyHtml, _.Html]);
}
else
{
// 大规模文章库:仅轻量字段,避免大表 LIKE 全表扫描
- exp &= _.Code == key | _.Title.Contains(key) | _.Summary.Contains(key) | _.UserName.Contains(key) | _.Format == key | _.Remark.Contains(key);
+ exp &= SearchWhereByKeys(key, [_.Code, _.Title, _.Summary, _.UserName, _.Format, _.Remark]);
}
}
diff --git "a/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\345\272\223.Biz.cs" "b/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\345\272\223.Biz.cs"
index 5dfbb2b..dd852d1 100644
--- "a/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\345\272\223.Biz.cs"
+++ "b/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\345\272\223.Biz.cs"
@@ -116,7 +116,11 @@ public partial class Book : Entity<Book>
if (groupId >= 0) exp &= _.GroupId == groupId;
if (enable != null) exp &= _.Enable == enable;
exp &= _.UpdateTime.Between(start, end);
- if (!key.IsNullOrEmpty()) exp &= _.Code.Contains(key) | _.Name.Contains(key) | _.Type.Contains(key) | _.UserName.Contains(key) | _.Slug.Contains(key) | _.Namespace.Contains(key) | _.CreateUser.Contains(key) | _.CreateIP.Contains(key) | _.UpdateUser.Contains(key) | _.UpdateIP.Contains(key) | _.Remark.Contains(key);
+ if (!key.IsNullOrEmpty())
+ {
+ // 空格分隔的多关键词,词间 And、词内多字段 Or;兼容全角空格分隔
+ exp &= SearchWhereByKeys(key.Replace('\u3000', ' '), [_.Code, _.Name, _.Type, _.UserName, _.Slug, _.Namespace, _.CreateUser, _.CreateIP, _.UpdateUser, _.UpdateIP, _.Remark]);
+ }
return FindAll(exp, page);
}
diff --git "a/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\347\273\204.Biz.cs" "b/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\347\273\204.Biz.cs"
index 76c7a47..63a5202 100644
--- "a/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\347\273\204.Biz.cs"
+++ "b/NewLife.YuqueWeb/Entity/\347\237\245\350\257\206\347\273\204.Biz.cs"
@@ -131,7 +131,11 @@ public partial class Group : Entity<Group>
if (!code.IsNullOrEmpty()) exp &= _.Code == code;
if (!name.IsNullOrEmpty()) exp &= _.Name == name;
exp &= _.UpdateTime.Between(start, end);
- if (!key.IsNullOrEmpty()) exp &= _.Code.Contains(key) | _.Name.Contains(key) | _.Type.Contains(key) | _.CreateUser.Contains(key) | _.CreateIP.Contains(key) | _.UpdateUser.Contains(key) | _.UpdateIP.Contains(key) | _.Remark.Contains(key);
+ if (!key.IsNullOrEmpty())
+ {
+ // 空格分隔的多关键词,词间 And、词内多字段 Or;兼容全角空格分隔
+ exp &= SearchWhereByKeys(key.Replace('\u3000', ' '), [_.Code, _.Name, _.Type, _.CreateUser, _.CreateIP, _.UpdateUser, _.UpdateIP, _.Remark]);
+ }
return FindAll(exp, page);
}
diff --git a/NewLife.YuqueWeb/Views/Yuque/Search.cshtml b/NewLife.YuqueWeb/Views/Yuque/Search.cshtml
index bd901d4..a8b7fa4 100644
--- a/NewLife.YuqueWeb/Views/Yuque/Search.cshtml
+++ b/NewLife.YuqueWeb/Views/Yuque/Search.cshtml
@@ -15,11 +15,18 @@
var pageUrl = cat == null ? "/search-" + key + "-pageIndex" : "/" + cat.Code + "-" + key + "-pageIndex";
var page = new PageModel { Page = Model.Page, Url = pageUrl };
- // 关键词高亮:先 HTML 编码再正则替换,避免 XSS(WebUtility 静态方法避免 Razor 动态调用问题)
- String pattern = Regex.Escape(key);
+ // 关键词高亮:空格拆为多词逐词高亮,各词转义后以 | 联合成单一正则一次替换(避免嵌套 <mark>);兼容全角空格
+ // 先 HTML 编码再正则替换,避免 XSS(WebUtility 静态方法避免 Razor 动态调用问题)
+ var _sb = new System.Text.StringBuilder();
+ foreach (var _w in key.Replace('\u3000', ' ').Split(' ', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (_sb.Length > 0) _sb.Append('|');
+ _sb.Append(Regex.Escape(_w));
+ }
+ String pattern = _sb.ToString();
String Highlight(String text)
{
- if (key.IsNullOrEmpty() || text.IsNullOrEmpty()) return text;
+ if (pattern.IsNullOrEmpty() || text.IsNullOrEmpty()) return text;
var encoded = System.Net.WebUtility.HtmlEncode(text);
return Regex.Replace(encoded, pattern, m => "<mark>" + m.Value + "</mark>", RegexOptions.IgnoreCase);
}