添加搜索

This commit is contained in:
2026-07-18 10:58:33 +08:00 Unverified
parent 3867373850
commit c8b2039593
11 changed files with 859 additions and 3 deletions
+2 -1
View File
@@ -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";
+13 -1
View File
@@ -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;
}
+297
View File
@@ -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>
+216
View File
@@ -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;
}
}