From c8b203959393171f0087cd6be89bcf1e7551aaad Mon Sep 17 00:00:00 2001 From: biss Date: Sat, 18 Jul 2026 10:58:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 9 + .gitignore | 3 + README.md | 21 ++ package-lock.json | 40 ++++ package.json | 8 +- scripts/create-typesense-search-key.mjs | 78 +++++++ scripts/sync-typesense.ts | 173 ++++++++++++++ src/data/site.ts | 3 +- src/env.d.ts | 14 +- src/pages/search.astro | 297 ++++++++++++++++++++++++ src/styles/global.css | 216 +++++++++++++++++ 11 files changed, 859 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 scripts/create-typesense-search-key.mjs create mode 100644 scripts/sync-typesense.ts create mode 100644 src/pages/search.astro diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6febee --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 2059543..a8fbc21 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,8 @@ dist/ .astro/ *.log .env.local +.env +.env.* +!.env.example .tmp/ gzhh/ diff --git a/README.md b/README.md index e3208c7..75c5c29 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index ada2ac6..3472ba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index cf813b8..d856641 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/create-typesense-search-key.mjs b/scripts/create-typesense-search-key.mjs new file mode 100644 index 0000000..67ddc7c --- /dev/null +++ b/scripts/create-typesense-search-key.mjs @@ -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。"); diff --git a/scripts/sync-typesense.ts b/scripts/sync-typesense.ts new file mode 100644 index 0000000..00b4e00 --- /dev/null +++ b/scripts/sync-typesense.ts @@ -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(); diff --git a/src/data/site.ts b/src/data/site.ts index d33376d..1000569 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -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"; diff --git a/src/env.d.ts b/src/env.d.ts index 9bc5cb4..1c0c5fa 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1 +1,13 @@ -/// \ No newline at end of file +/// + +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; +} diff --git a/src/pages/search.astro b/src/pages/search.astro new file mode 100644 index 0000000..02e7dbb --- /dev/null +++ b/src/pages/search.astro @@ -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); +--- + + +
+
+
+
+ 返回首页 +

Search Class 612

+

全站搜索

+

在时间线、照片、青春刊物和同学近况中寻找一段记忆。

+
+
+
+ +
+

搜索班级记忆

+ { + isConfigured ? ( +
+ + +
+ + + + +
+ +

正在载入记忆……

+
+ +
+ ) : ( +
+

Typesense 待配置

+

搜索服务还没有连接

+

+ 请参考 .env.example 配置公开的 Search-only Key,并运行 npm run search:sync 建立索引。 +

+
+ ) + } +
+
+ + { + isConfigured && ( + + ) + } +
diff --git a/src/styles/global.css b/src/styles/global.css index 0518fc0..4d78cd4 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; + } +}