添加搜索
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 浏览器可见,只能填写限制为 documents:search 权限的 Search-only Key。
|
||||
PUBLIC_TYPESENSE_HOST=search.example.com
|
||||
PUBLIC_TYPESENSE_PORT=443
|
||||
PUBLIC_TYPESENSE_PROTOCOL=https
|
||||
PUBLIC_TYPESENSE_SEARCH_API_KEY=replace-with-search-only-key
|
||||
PUBLIC_TYPESENSE_COLLECTION=class_memories
|
||||
|
||||
# 仅供 npm run search:sync 使用,绝不能使用 PUBLIC_ 前缀。
|
||||
TYPESENSE_ADMIN_API_KEY=replace-with-admin-key
|
||||
@@ -3,5 +3,8 @@ dist/
|
||||
.astro/
|
||||
*.log
|
||||
.env.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.tmp/
|
||||
gzhh/
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- 如今的我们:展示同学卡片和个人近况页面。
|
||||
- 综合试卷:把纪念试卷做成网页,并保留 Word 原卷下载。
|
||||
- 留言墙:接入 Twikoo 后可在页面中留言。
|
||||
- 全站搜索:通过 Typesense 搜索并筛选时间线、照片、文章和同学公开资料。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -33,8 +34,28 @@ npm run dev
|
||||
npm run dev # 启动开发服务器
|
||||
npm run build # 构建静态站点到 dist
|
||||
npm run preview # 预览构建结果
|
||||
npm run search:sync # 将公开内容同步到 Typesense
|
||||
```
|
||||
|
||||
## Typesense 全站搜索
|
||||
|
||||
复制 `.env.example` 中的配置到本地 `.env`、`.env.local` 或部署平台的环境变量。`npm run search:sync` 会自动读取这两个本地文件,且 `.env.local` 中的同名配置优先:
|
||||
|
||||
- `PUBLIC_TYPESENSE_*` 会进入浏览器,只能使用具有搜索权限的 Search-only Key。
|
||||
- `TYPESENSE_ADMIN_API_KEY` 只用于同步索引,不能添加 `PUBLIC_` 前缀。
|
||||
|
||||
配置完成后直接执行,不需要在终端中逐个设置环境变量:
|
||||
|
||||
```bash
|
||||
npm run search:key
|
||||
npm run search:sync
|
||||
npm run build
|
||||
```
|
||||
|
||||
`npm run search:key` 使用 `TYPESENSE_ADMIN_API_KEY` 创建一个仅能搜索 `class_memories` 集合的只读密钥,并自动写入 `.env.local`。如果已经存在只读密钥,脚本会停止;需要轮换时使用 `npm run search:key -- --force`。
|
||||
|
||||
同步脚本会在集合不存在时创建 `class_memories`,然后批量更新现有搜索文档。部署流程应在内容更新后先执行同步,再构建和发布站点。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
|
||||
Generated
+40
@@ -11,6 +11,10 @@
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"astro": "^7.1.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"tsx": "^4.23.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/check": {
|
||||
@@ -2124,6 +2128,16 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -4406,6 +4420,25 @@
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typesafe-path": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz",
|
||||
@@ -4452,6 +4485,13 @@
|
||||
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
+7
-1
@@ -6,11 +6,17 @@
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
"preview": "astro preview",
|
||||
"search:key": "node --env-file-if-exists=.env --env-file-if-exists=.env.local scripts/create-typesense-search-key.mjs",
|
||||
"search:sync": "node --env-file-if-exists=.env --env-file-if-exists=.env.local --import tsx scripts/sync-typesense.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"astro": "^7.1.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.1.1",
|
||||
"tsx": "^4.23.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const collectionName = process.env.PUBLIC_TYPESENSE_COLLECTION || "class_memories";
|
||||
const host = process.env.PUBLIC_TYPESENSE_HOST?.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const port = process.env.PUBLIC_TYPESENSE_PORT || "443";
|
||||
const protocol = process.env.PUBLIC_TYPESENSE_PROTOCOL || "https";
|
||||
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
|
||||
const existingSearchKey = process.env.PUBLIC_TYPESENSE_SEARCH_API_KEY;
|
||||
const force = process.argv.includes("--force");
|
||||
|
||||
if (!host || !adminKey) {
|
||||
throw new Error(
|
||||
"缺少 Typesense 配置。请在 .env 或 .env.local 中设置 PUBLIC_TYPESENSE_HOST 和 TYPESENSE_ADMIN_API_KEY。"
|
||||
);
|
||||
}
|
||||
|
||||
const isPlaceholder = (value) =>
|
||||
!value || /replace|example|your|请填写|只读密钥/i.test(value);
|
||||
|
||||
if (!isPlaceholder(existingSearchKey) && !force) {
|
||||
throw new Error(
|
||||
"PUBLIC_TYPESENSE_SEARCH_API_KEY 已存在。如需轮换密钥,请执行 npm run search:key -- --force。"
|
||||
);
|
||||
}
|
||||
|
||||
const origin = `${protocol}://${host}${
|
||||
(protocol === "https" && port === "443") || (protocol === "http" && port === "80")
|
||||
? ""
|
||||
: `:${port}`
|
||||
}`;
|
||||
|
||||
const response = await fetch(`${origin}/keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-TYPESENSE-API-KEY": adminKey
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: `Search-only key for ${collectionName}`,
|
||||
actions: ["documents:search"],
|
||||
collections: [collectionName]
|
||||
})
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`创建只读密钥失败:${response.status} ${responseText}`);
|
||||
}
|
||||
|
||||
const result = JSON.parse(responseText);
|
||||
if (!result.value) {
|
||||
throw new Error("Typesense 没有返回完整密钥,无法保存配置。请检查服务端响应。" );
|
||||
}
|
||||
|
||||
const envLocalPath = fileURLToPath(new URL("../.env.local", import.meta.url));
|
||||
let envLocal = "";
|
||||
try {
|
||||
envLocal = await readFile(envLocalPath, "utf8");
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const lineEnding = envLocal.includes("\r\n") ? "\r\n" : "\n";
|
||||
const assignment = `PUBLIC_TYPESENSE_SEARCH_API_KEY=${JSON.stringify(result.value)}`;
|
||||
|
||||
if (/^PUBLIC_TYPESENSE_SEARCH_API_KEY=.*$/m.test(envLocal)) {
|
||||
envLocal = envLocal.replace(/^PUBLIC_TYPESENSE_SEARCH_API_KEY=.*$/m, assignment);
|
||||
} else {
|
||||
const separator = envLocal.length === 0 || envLocal.endsWith("\n") ? "" : lineEnding;
|
||||
envLocal = `${envLocal}${separator}${assignment}${lineEnding}`;
|
||||
}
|
||||
|
||||
await writeFile(envLocalPath, envLocal, "utf8");
|
||||
|
||||
console.log(`已创建仅限 ${collectionName} 的 documents:search 密钥。`);
|
||||
console.log(`密钥 ID:${result.id},已写入 .env.local。`);
|
||||
console.log("现在可以执行 npm run search:sync 和 npm run build。");
|
||||
@@ -0,0 +1,173 @@
|
||||
import { articles } from "../src/data/articles";
|
||||
import { people } from "../src/data/people";
|
||||
import { photoTopics } from "../src/data/photos";
|
||||
import { timeline } from "../src/data/timeline";
|
||||
|
||||
interface SearchDocument {
|
||||
id: string;
|
||||
type: "时间线" | "照片" | "文章" | "同学";
|
||||
title: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
year?: number;
|
||||
timestamp?: number;
|
||||
url: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const collectionName = process.env.PUBLIC_TYPESENSE_COLLECTION || "class_memories";
|
||||
const host = process.env.PUBLIC_TYPESENSE_HOST?.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const port = process.env.PUBLIC_TYPESENSE_PORT || "443";
|
||||
const protocol = process.env.PUBLIC_TYPESENSE_PROTOCOL || "https";
|
||||
const adminKey = process.env.TYPESENSE_ADMIN_API_KEY;
|
||||
|
||||
if (!host || !adminKey) {
|
||||
throw new Error(
|
||||
"缺少 Typesense 配置。请设置 PUBLIC_TYPESENSE_HOST 和 TYPESENSE_ADMIN_API_KEY。"
|
||||
);
|
||||
}
|
||||
|
||||
const origin = `${protocol}://${host}${
|
||||
(protocol === "https" && port === "443") || (protocol === "http" && port === "80")
|
||||
? ""
|
||||
: `:${port}`
|
||||
}`;
|
||||
|
||||
const schema = {
|
||||
name: collectionName,
|
||||
fields: [
|
||||
{ name: "title", type: "string", locale: "zh" },
|
||||
{ name: "content", type: "string", locale: "zh" },
|
||||
{ name: "type", type: "string", facet: true },
|
||||
{ name: "category", type: "string", facet: true, optional: true },
|
||||
{ name: "tags", type: "string[]", facet: true, optional: true },
|
||||
{ name: "year", type: "int32", facet: true, optional: true },
|
||||
{ name: "timestamp", type: "int64", optional: true },
|
||||
{ name: "url", type: "string", index: false },
|
||||
{ name: "image", type: "string", index: false, optional: true }
|
||||
]
|
||||
};
|
||||
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase("zh-CN")
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80);
|
||||
|
||||
const getYear = (value: string) => Number(value.match(/\d{4}/)?.[0]) || undefined;
|
||||
|
||||
const toTimestamp = (value: string) => {
|
||||
const timestamp = Date.parse(`${value}T00:00:00+08:00`);
|
||||
return Number.isNaN(timestamp) ? undefined : Math.floor(timestamp / 1000);
|
||||
};
|
||||
|
||||
const documents: SearchDocument[] = [
|
||||
...timeline.map((moment, index) => ({
|
||||
id: `timeline-${index + 1}-${slugify(moment.title)}`,
|
||||
type: "时间线" as const,
|
||||
title: moment.title,
|
||||
content: `${moment.date} ${moment.text}`,
|
||||
category: "高中三年",
|
||||
tags: ["时间线", moment.title],
|
||||
year: getYear(moment.date),
|
||||
url: "/timeline/"
|
||||
})),
|
||||
...photoTopics.flatMap((topic) =>
|
||||
topic.photos.map((photo, index) => ({
|
||||
id: `photo-${topic.slug}-${index + 1}`,
|
||||
type: "照片" as const,
|
||||
title: photo.title,
|
||||
content: `${topic.title} ${photo.caption}`,
|
||||
category: topic.title,
|
||||
tags: ["照片", topic.title],
|
||||
url: `/gallery/${topic.slug}/`,
|
||||
image: photo.image
|
||||
}))
|
||||
),
|
||||
...articles.map((article, index) => ({
|
||||
id: `article-${index + 1}-${slugify(article.fileName)}`,
|
||||
type: "文章" as const,
|
||||
title: article.title,
|
||||
content: article.excerpt,
|
||||
category: article.category,
|
||||
tags: ["公众号", article.category],
|
||||
year: getYear(article.date),
|
||||
timestamp: toTimestamp(article.date),
|
||||
url: article.sourceUrl,
|
||||
image: article.cover
|
||||
})),
|
||||
...people.map((person) => ({
|
||||
id: `person-${person.slug}`,
|
||||
type: "同学" as const,
|
||||
title: person.name,
|
||||
content: [person.location, person.school, person.direction, person.text, person.currentStatus]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
category: person.direction,
|
||||
tags: person.keywords,
|
||||
url: `/people/${person.slug}/`,
|
||||
image: person.photo || undefined
|
||||
}))
|
||||
];
|
||||
|
||||
const request = (path: string, init: RequestInit = {}) =>
|
||||
fetch(`${origin}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"X-TYPESENSE-API-KEY": adminKey,
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
|
||||
const ensureCollection = async () => {
|
||||
const response = await request(`/collections/${encodeURIComponent(collectionName)}`);
|
||||
if (response.ok) return;
|
||||
if (response.status !== 404) {
|
||||
throw new Error(`读取集合失败:${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const createResponse = await request("/collections", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(schema)
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`创建集合失败:${createResponse.status} ${await createResponse.text()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
await ensureCollection();
|
||||
|
||||
const payload = documents.map((document) => JSON.stringify(document)).join("\n");
|
||||
const response = await request(
|
||||
`/collections/${encodeURIComponent(collectionName)}/documents/import?action=upsert`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
body: payload
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`导入失败:${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const results = (await response.text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as { success: boolean; error?: string; document?: string });
|
||||
const failures = results.filter((result) => !result.success);
|
||||
|
||||
if (failures.length > 0) {
|
||||
failures.slice(0, 10).forEach((failure) => console.error(failure.error, failure.document));
|
||||
throw new Error(`${failures.length} 条搜索文档同步失败。`);
|
||||
}
|
||||
|
||||
console.log(`已同步 ${results.length} 条文档到 Typesense 集合 ${collectionName}。`);
|
||||
};
|
||||
|
||||
await sync();
|
||||
+2
-1
@@ -34,7 +34,8 @@ export const navItems = [
|
||||
{ label: "青春刊物", href: "/articles/" },
|
||||
{ label: "如今的我们", href: "/people/" },
|
||||
{ label: "综合试卷", href: "/exam/" },
|
||||
{ label: "留言墙", href: "/messages/" }
|
||||
{ label: "留言墙", href: "/messages/" },
|
||||
{ label: "搜索", href: "/search/" }
|
||||
];
|
||||
|
||||
const leavingCampusDate = "2024-06-08";
|
||||
|
||||
Vendored
+13
-1
@@ -1 +1,13 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly PUBLIC_TYPESENSE_HOST?: string;
|
||||
readonly PUBLIC_TYPESENSE_PORT?: string;
|
||||
readonly PUBLIC_TYPESENSE_PROTOCOL?: "http" | "https";
|
||||
readonly PUBLIC_TYPESENSE_SEARCH_API_KEY?: string;
|
||||
readonly PUBLIC_TYPESENSE_COLLECTION?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
const typesenseConfig = {
|
||||
host: import.meta.env.PUBLIC_TYPESENSE_HOST ?? "",
|
||||
port: import.meta.env.PUBLIC_TYPESENSE_PORT ?? "443",
|
||||
protocol: import.meta.env.PUBLIC_TYPESENSE_PROTOCOL ?? "https",
|
||||
apiKey: import.meta.env.PUBLIC_TYPESENSE_SEARCH_API_KEY ?? "",
|
||||
collection: import.meta.env.PUBLIC_TYPESENSE_COLLECTION ?? "class_memories"
|
||||
};
|
||||
|
||||
const isConfigured = Boolean(typesenseConfig.host && typesenseConfig.apiKey);
|
||||
---
|
||||
|
||||
<BaseLayout title={`全站搜索 · ${site.className}`}>
|
||||
<main>
|
||||
<section class="page-hero search-hero">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<a class="back-link" href="/">返回首页</a>
|
||||
<p class="eyebrow">Search Class 612</p>
|
||||
<h1>全站搜索</h1>
|
||||
<p>在时间线、照片、青春刊物和同学近况中寻找一段记忆。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="search-section" aria-labelledby="search-title">
|
||||
<h2 id="search-title" class="sr-only">搜索班级记忆</h2>
|
||||
{
|
||||
isConfigured ? (
|
||||
<div class="search-app" data-search-app>
|
||||
<form class="search-form" role="search" data-search-form>
|
||||
<label for="site-search">关键词</label>
|
||||
<div class="search-input-row">
|
||||
<input
|
||||
id="site-search"
|
||||
name="q"
|
||||
type="search"
|
||||
placeholder="试试“毕业”“百日誓师”或同学姓名"
|
||||
autocomplete="off"
|
||||
data-search-input
|
||||
/>
|
||||
<button class="button primary" type="submit">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="search-filters" aria-label="搜索筛选">
|
||||
<label>
|
||||
内容类型
|
||||
<select data-filter="type">
|
||||
<option value="">全部类型</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
分类
|
||||
<select data-filter="category">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
年份
|
||||
<select data-filter="year">
|
||||
<option value="">全部年份</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="search-clear" type="button" data-search-clear>清除筛选</button>
|
||||
</div>
|
||||
|
||||
<p class="search-status" aria-live="polite" data-search-status>正在载入记忆……</p>
|
||||
<div class="search-results" data-search-results></div>
|
||||
<nav class="search-pagination" aria-label="搜索结果分页" data-search-pagination></nav>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-config-note">
|
||||
<p class="eyebrow">Typesense 待配置</p>
|
||||
<h2>搜索服务还没有连接</h2>
|
||||
<p>
|
||||
请参考 <code>.env.example</code> 配置公开的 Search-only Key,并运行 <code>npm run search:sync</code> 建立索引。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{
|
||||
isConfigured && (
|
||||
<script is:inline define:vars={{ typesenseConfig }}>
|
||||
const app = document.querySelector("[data-search-app]");
|
||||
const form = app?.querySelector("[data-search-form]");
|
||||
const input = app?.querySelector("[data-search-input]");
|
||||
const status = app?.querySelector("[data-search-status]");
|
||||
const results = app?.querySelector("[data-search-results]");
|
||||
const pagination = app?.querySelector("[data-search-pagination]");
|
||||
const clearButton = app?.querySelector("[data-search-clear]");
|
||||
const filters = [...(app?.querySelectorAll("[data-filter]") ?? [])];
|
||||
const perPage = 12;
|
||||
let page = 1;
|
||||
let debounceTimer;
|
||||
let requestController;
|
||||
|
||||
const origin = `${typesenseConfig.protocol}://${typesenseConfig.host}${
|
||||
(typesenseConfig.protocol === "https" && typesenseConfig.port === "443") ||
|
||||
(typesenseConfig.protocol === "http" && typesenseConfig.port === "80")
|
||||
? ""
|
||||
: `:${typesenseConfig.port}`
|
||||
}`;
|
||||
|
||||
const setSelectOptions = (select, values) => {
|
||||
const current = select.value;
|
||||
const firstOption = select.options[0];
|
||||
select.replaceChildren(firstOption);
|
||||
values.forEach(({ value, count }) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = `${value}(${count})`;
|
||||
select.append(option);
|
||||
});
|
||||
select.value = current;
|
||||
};
|
||||
|
||||
const updateFacets = (facetCounts = []) => {
|
||||
filters.forEach((select) => {
|
||||
const facet = facetCounts.find((item) => item.field_name === select.dataset.filter);
|
||||
const values = (facet?.counts ?? []).map((item) => ({
|
||||
value: String(item.value),
|
||||
count: item.count
|
||||
}));
|
||||
if (select.dataset.filter === "year") {
|
||||
values.sort((a, b) => Number(b.value) - Number(a.value));
|
||||
}
|
||||
setSelectOptions(select, values);
|
||||
});
|
||||
};
|
||||
|
||||
const renderResults = (hits = []) => {
|
||||
results.replaceChildren();
|
||||
hits.forEach(({ document: item }) => {
|
||||
const article = document.createElement("article");
|
||||
article.className = "search-result-card";
|
||||
|
||||
if (item.image) {
|
||||
const image = document.createElement("img");
|
||||
image.src = item.image;
|
||||
image.alt = "";
|
||||
image.loading = "lazy";
|
||||
article.append(image);
|
||||
}
|
||||
|
||||
const body = document.createElement("div");
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "search-result-meta";
|
||||
meta.textContent = [item.type, item.category, item.year].filter(Boolean).join(" · ");
|
||||
|
||||
const title = document.createElement("h2");
|
||||
const link = document.createElement("a");
|
||||
link.href = item.url;
|
||||
link.textContent = item.title;
|
||||
if (/^https?:\/\//.test(item.url)) {
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
}
|
||||
title.append(link);
|
||||
|
||||
const content = document.createElement("p");
|
||||
content.textContent = item.content;
|
||||
body.append(meta, title, content);
|
||||
article.append(body);
|
||||
results.append(article);
|
||||
});
|
||||
};
|
||||
|
||||
const renderPagination = (found) => {
|
||||
pagination.replaceChildren();
|
||||
const totalPages = Math.ceil(found / perPage);
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const makeButton = (label, nextPage, disabled = false) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
button.addEventListener("click", () => {
|
||||
page = nextPage;
|
||||
search();
|
||||
app.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
return button;
|
||||
};
|
||||
|
||||
pagination.append(
|
||||
makeButton("上一页", page - 1, page <= 1),
|
||||
document.createTextNode(`第 ${page} / ${totalPages} 页`),
|
||||
makeButton("下一页", page + 1, page >= totalPages)
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrl = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (input.value.trim()) params.set("q", input.value.trim());
|
||||
filters.forEach((select) => {
|
||||
if (select.value) params.set(select.dataset.filter, select.value);
|
||||
});
|
||||
if (page > 1) params.set("page", String(page));
|
||||
history.replaceState(null, "", `${location.pathname}${params.size ? `?${params}` : ""}`);
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
requestController?.abort();
|
||||
requestController = new AbortController();
|
||||
status.textContent = "正在寻找记忆……";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: input.value.trim() || "*",
|
||||
query_by: "title,content,tags",
|
||||
query_by_weights: "5,2,3",
|
||||
facet_by: "type,category,year",
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
include_fields: "title,content,type,category,tags,year,url,image"
|
||||
});
|
||||
const conditions = filters
|
||||
.filter((select) => select.value)
|
||||
.map((select) => `${select.dataset.filter}:=${select.value}`);
|
||||
if (conditions.length) params.set("filter_by", conditions.join(" && "));
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${origin}/collections/${encodeURIComponent(typesenseConfig.collection)}/documents/search?${params}`,
|
||||
{
|
||||
headers: { "X-TYPESENSE-API-KEY": typesenseConfig.apiKey },
|
||||
signal: requestController.signal
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
|
||||
const data = await response.json();
|
||||
renderResults(data.hits);
|
||||
updateFacets(data.facet_counts);
|
||||
renderPagination(data.found);
|
||||
status.textContent = data.found
|
||||
? `找到 ${data.found} 段相关记忆`
|
||||
: "没有找到相关内容,换个关键词试试吧。";
|
||||
updateUrl();
|
||||
} catch (error) {
|
||||
if (error.name === "AbortError") return;
|
||||
console.error(error);
|
||||
results.replaceChildren();
|
||||
pagination.replaceChildren();
|
||||
status.textContent = "搜索服务暂时无法访问,请稍后重试。";
|
||||
}
|
||||
};
|
||||
|
||||
const initialParams = new URLSearchParams(location.search);
|
||||
input.value = initialParams.get("q") ?? "";
|
||||
page = Math.max(1, Number(initialParams.get("page")) || 1);
|
||||
filters.forEach((select) => {
|
||||
const value = initialParams.get(select.dataset.filter);
|
||||
if (value) {
|
||||
const option = document.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = value;
|
||||
select.append(option);
|
||||
select.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
page = 1;
|
||||
search();
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
page = 1;
|
||||
debounceTimer = setTimeout(search, 280);
|
||||
});
|
||||
filters.forEach((select) =>
|
||||
select.addEventListener("change", () => {
|
||||
page = 1;
|
||||
search();
|
||||
})
|
||||
);
|
||||
clearButton.addEventListener("click", () => {
|
||||
input.value = "";
|
||||
filters.forEach((select) => (select.value = ""));
|
||||
page = 1;
|
||||
search();
|
||||
input.focus();
|
||||
});
|
||||
|
||||
search();
|
||||
</script>
|
||||
)
|
||||
}
|
||||
</BaseLayout>
|
||||
@@ -2602,3 +2602,219 @@ h2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.search-app,
|
||||
.search-config-note {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(20px, 4vw, 40px);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.search-form > label,
|
||||
.search-filters label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input-row input,
|
||||
.search-filters select {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search-input-row input {
|
||||
padding: 0 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.search-input-row input:focus,
|
||||
.search-filters select:focus {
|
||||
outline: 3px solid rgba(69, 111, 148, 0.2);
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.search-filters select {
|
||||
padding: 0 38px 0 12px;
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-status {
|
||||
min-height: 26px;
|
||||
margin: 26px 0 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
display: grid;
|
||||
grid-template-columns: 148px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--paper);
|
||||
transition: transform 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.search-result-card:not(:has(img)) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(55, 109, 90, 0.42);
|
||||
}
|
||||
|
||||
.search-result-card img {
|
||||
width: 148px;
|
||||
height: 112px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.search-result-card h2 {
|
||||
margin: 3px 0 6px;
|
||||
font-size: clamp(19px, 2vw, 24px);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.search-result-card h2 a:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.search-result-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-result-card div > p:last-child {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.search-result-meta {
|
||||
color: var(--green);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search-pagination button {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-config-note code {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--soft);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.search-filters {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.search-result-card img {
|
||||
width: 104px;
|
||||
height: 92px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.search-input-row,
|
||||
.search-filters,
|
||||
.search-result-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-input-row .button,
|
||||
.search-clear {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-result-card img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user