添加搜索
This commit is contained in:
+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