feat(search): add global search across wiki entities

Implement /api/search endpoint for cross-entity querying
Add GlobalSearch component to top navigation bar with categorized results
This commit is contained in:
2026-05-04 14:20:12 +08:00
parent 3dd3998a5c
commit 06e0cbb1c1
8 changed files with 784 additions and 0 deletions

View File

@@ -9,6 +9,7 @@
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList 和公开可见的 Life Post结果跳转到对应公开详情页或页面锚点。
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
## 技术栈
@@ -23,6 +24,7 @@
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
- API 只返回业务需要的字段不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片不返回编辑审计、权限、审核原因、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。

View File

@@ -36,6 +36,31 @@ type DataToolsBundle = {
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, DataToolScopeData>>;
};
type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life';
type GlobalSearchItem = {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImageValue | PokemonImage | null;
};
type GlobalSearchGroup = {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
};
type GlobalSearchResults = {
query: string;
groups: GlobalSearchGroup[];
};
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
@@ -2411,6 +2436,179 @@ export async function listDailyChecklistItems(locale = defaultLocale) {
);
}
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
if (!search) {
return { query: '', groups: [] };
}
const pattern = `%${search}%`;
const limit = 5;
const pokemonName = localizedName('pokemon', 'p', locale);
const habitatName = localizedName('habitats', 'h', locale);
const itemName = localizedName('items', 'i', locale);
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
const artifactName = localizedName('ancient-artifacts', 'a', locale);
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
const recipeItemName = localizedName('items', 'result_item', locale);
const recipeMaterialName = localizedName('items', 'material_item', locale);
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
const [pokemon, habitats, items, artifacts, recipes, checklist, life] = await Promise.all([
query<GlobalSearchItem>(
`
SELECT
p.id,
'pokemon' AS type,
${pokemonName} AS title,
'/pokemon/' || p.id AS url,
NULLIF(p.genus, '') AS summary,
'#' || p.display_id::text AS meta,
${pokemonImageJson('p')} AS image
FROM pokemon p
WHERE ${pokemonName} ILIKE $1
ORDER BY ${orderByEntity('p')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
h.id,
'habitats' AS type,
${habitatName} AS title,
'/habitats/' || h.id AS url,
NULL AS summary,
NULL AS meta,
${uploadedImageJson('h.image_path')} AS image
FROM habitats h
WHERE ${habitatName} ILIKE $1
ORDER BY ${orderByEntity('h')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
i.id,
'items' AS type,
${itemName} AS title,
'/items/' || i.id AS url,
NULLIF(i.details, '') AS summary,
(${itemCategoryName}->>'name') AS meta,
${uploadedImageJson('i.image_path')} AS image
FROM items i
WHERE ${itemName} ILIKE $1
ORDER BY i.display_id, ${orderByEntity('i')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
a.id,
'ancient-artifacts' AS type,
${artifactName} AS title,
'/ancient-artifacts/' || a.id AS url,
NULLIF(a.details, '') AS summary,
(${artifactCategoryName}->>'name') AS meta,
${uploadedImageJson('a.image_path')} AS image
FROM ancient_artifacts a
WHERE ${artifactName} ILIKE $1
ORDER BY a.display_id, ${orderByEntity('a')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
r.id,
'recipes' AS type,
${recipeItemName} AS title,
'/recipes/' || r.id AS url,
(
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
FROM (
SELECT DISTINCT ${recipeMaterialName} AS name
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
) material_rows
) AS summary,
NULL AS meta,
${uploadedImageJson('result_item.image_path')} AS image
FROM recipes r
JOIN items result_item ON result_item.id = r.item_id
WHERE ${recipeItemName} ILIKE $1
OR EXISTS (
SELECT 1
FROM recipe_materials rm
JOIN items material_item ON material_item.id = rm.item_id
WHERE rm.recipe_id = r.id
AND ${recipeMaterialName} ILIKE $1
)
ORDER BY ${orderByEntity('r')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
c.id,
'daily-checklist' AS type,
${checklistTitle} AS title,
'/checklist' AS url,
NULL AS summary,
NULL AS meta,
NULL AS image
FROM daily_checklist_items c
WHERE ${checklistTitle} ILIKE $1
ORDER BY ${orderByEntity('c')}
LIMIT $2
`,
[pattern, limit]
),
query<GlobalSearchItem>(
`
SELECT
lp.id,
'life' AS type,
LEFT(lp.body, 120) AS title,
'/life/' || lp.id AS url,
NULL AS summary,
${lifeCategoryName} AS meta,
NULL AS image
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
WHERE lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lp.body ILIKE $1
ORDER BY lp.created_at DESC, lp.id DESC
LIMIT $2
`,
[pattern, limit]
)
]);
const groups: GlobalSearchGroup[] = [
{ type: 'pokemon', items: pokemon },
{ type: 'habitats', items: habitats },
{ type: 'items', items: items },
{ type: 'ancient-artifacts', items: artifacts },
{ type: 'recipes', items: recipes },
{ type: 'daily-checklist', items: checklist },
{ type: 'life', items: life }
];
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
}
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
return queryOne(

View File

@@ -73,6 +73,7 @@ import {
getPokemon,
getPublicUserProfile,
getRecipe,
globalSearch,
importAdminData,
isConfigType,
listAncientArtifacts,
@@ -219,6 +220,10 @@ app.setErrorHandler(async (error, _request, reply) => {
app.get('/health', async () => ({ ok: true }));
app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;

View File

@@ -16,6 +16,7 @@ import {
type AppIcon
} from '../icons';
import type { AuthUser, Language } from '../services/api';
import GlobalSearch from './GlobalSearch.vue';
import NotificationBell from './NotificationBell.vue';
import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue';
@@ -271,6 +272,8 @@ onBeforeUnmount(() => {
</RouterLink>
</div>
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
<div class="site-topbar__spacer" aria-hidden="true"></div>
<div class="topbar-actions">

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { iconClose, iconSearch } from '../icons';
import { api, type GlobalSearchGroup, type GlobalSearchGroupType, type GlobalSearchItem } from '../services/api';
const emit = defineEmits<{
navigate: [];
}>();
const { t } = useI18n();
const router = useRouter();
const root = ref<HTMLElement | null>(null);
const input = ref<HTMLInputElement | null>(null);
const query = ref('');
const groups = ref<GlobalSearchGroup[]>([]);
const open = ref(false);
const mobileOpen = ref(false);
const loading = ref(false);
const failed = ref(false);
let searchTimeout: number | null = null;
let abortController: AbortController | null = null;
let requestId = 0;
const cleanQuery = computed(() => query.value.trim());
const hasResults = computed(() => groups.value.some((group) => group.items.length > 0));
const firstResult = computed(() => groups.value.find((group) => group.items.length > 0)?.items[0] ?? null);
const panelVisible = computed(() => open.value && cleanQuery.value !== '' && (loading.value || failed.value || groups.value.length > 0));
const groupLabels: Record<GlobalSearchGroupType, string> = {
pokemon: 'search.groups.pokemon',
habitats: 'search.groups.habitats',
items: 'search.groups.items',
'ancient-artifacts': 'search.groups.ancientArtifacts',
recipes: 'search.groups.recipes',
'daily-checklist': 'search.groups.dailyChecklist',
life: 'search.groups.life'
};
function clearSearchTimeout() {
if (searchTimeout !== null) {
window.clearTimeout(searchTimeout);
searchTimeout = null;
}
}
function abortSearch() {
abortController?.abort();
abortController = null;
}
function resetResults() {
groups.value = [];
failed.value = false;
loading.value = false;
}
async function runSearch(value: string) {
const currentRequestId = ++requestId;
abortSearch();
const controller = new AbortController();
abortController = controller;
loading.value = true;
failed.value = false;
try {
const response = await api.globalSearch(value, controller.signal);
if (currentRequestId === requestId) {
groups.value = response.groups;
}
} catch (error) {
if (controller.signal.aborted) {
return;
}
if (currentRequestId === requestId) {
groups.value = [];
failed.value = true;
}
} finally {
if (currentRequestId === requestId) {
loading.value = false;
if (abortController === controller) {
abortController = null;
}
}
}
}
function scheduleSearch() {
clearSearchTimeout();
const value = cleanQuery.value;
if (!value) {
requestId += 1;
abortSearch();
resetResults();
return;
}
requestId += 1;
abortSearch();
loading.value = true;
failed.value = false;
searchTimeout = window.setTimeout(() => {
searchTimeout = null;
void runSearch(value);
}, 240);
}
function openPanel() {
open.value = true;
}
function closePanel() {
open.value = false;
}
function toggleMobileSearch() {
mobileOpen.value = !mobileOpen.value;
openPanel();
if (mobileOpen.value) {
void nextTick(() => input.value?.focus());
}
}
function clearQuery() {
query.value = '';
resetResults();
openPanel();
void nextTick(() => input.value?.focus());
}
function onSubmit() {
const item = firstResult.value;
if (!item) {
openPanel();
return;
}
void navigateTo(item);
}
async function navigateTo(item: GlobalSearchItem) {
selectResult();
await router.push(item.url);
}
function selectResult() {
closePanel();
mobileOpen.value = false;
emit('navigate');
}
function onRootKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
closePanel();
input.value?.blur();
}
}
function onDocumentPointerDown(event: PointerEvent) {
if (root.value && !root.value.contains(event.target as Node)) {
closePanel();
}
}
function groupLabel(type: GlobalSearchGroupType) {
return t(groupLabels[type]);
}
watch(query, scheduleSearch);
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown);
});
onBeforeUnmount(() => {
clearSearchTimeout();
abortSearch();
document.removeEventListener('pointerdown', onDocumentPointerDown);
});
</script>
<template>
<div
ref="root"
class="global-search"
:class="{ 'global-search--mobile-open': mobileOpen }"
@keydown="onRootKeydown"
>
<button
class="global-search__toggle"
type="button"
:aria-label="t('search.open')"
:aria-expanded="mobileOpen"
@click="toggleMobileSearch"
>
<Icon :icon="mobileOpen ? iconClose : iconSearch" class="ui-icon" aria-hidden="true" />
</button>
<form class="global-search__form" role="search" @submit.prevent="onSubmit">
<Icon :icon="iconSearch" class="ui-icon global-search__form-icon" aria-hidden="true" />
<input
ref="input"
v-model="query"
class="global-search__input"
type="search"
:placeholder="t('search.placeholder')"
:aria-label="t('search.label')"
:aria-controls="panelVisible ? 'global-search-results' : undefined"
:aria-expanded="panelVisible"
autocomplete="off"
@focus="openPanel"
/>
<button
v-if="cleanQuery"
class="global-search__clear"
type="button"
:aria-label="t('search.clear')"
@click="clearQuery"
>
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
</button>
</form>
<div
v-if="panelVisible"
id="global-search-results"
class="global-search__panel"
:aria-busy="loading"
>
<div v-if="loading" class="global-search__skeleton" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<p v-else-if="failed" class="global-search__message">{{ t('search.failed') }}</p>
<p v-else-if="!hasResults" class="global-search__message">{{ t('search.empty') }}</p>
<template v-else>
<section
v-for="group in groups"
:key="group.type"
class="global-search__group"
:aria-label="groupLabel(group.type)"
>
<h2 class="global-search__group-title">{{ groupLabel(group.type) }}</h2>
<RouterLink
v-for="item in group.items"
:key="`${group.type}-${item.id}`"
class="global-search__result"
:to="item.url"
@click="selectResult"
>
<img
v-if="item.image"
class="global-search__result-image"
:src="item.image.url"
:alt="item.title"
loading="lazy"
/>
<span v-else class="global-search__result-mark" aria-hidden="true">
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
</span>
<span class="global-search__result-copy">
<span class="global-search__result-title">{{ item.title }}</span>
<span v-if="item.summary || item.meta" class="global-search__result-meta">
<span v-if="item.meta">{{ item.meta }}</span>
<span v-if="item.summary">{{ item.summary }}</span>
</span>
</span>
</RouterLink>
</section>
</template>
</div>
</div>
</template>

View File

@@ -318,6 +318,35 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type GlobalSearchGroupType =
| 'pokemon'
| 'habitats'
| 'items'
| 'ancient-artifacts'
| 'recipes'
| 'daily-checklist'
| 'life';
export interface GlobalSearchItem {
id: number;
type: GlobalSearchGroupType;
title: string;
url: string;
summary: string | null;
meta: string | null;
image: EntityImage | PokemonImage | null;
}
export interface GlobalSearchGroup {
type: GlobalSearchGroupType;
items: GlobalSearchItem[];
}
export interface GlobalSearchResults {
query: string;
groups: GlobalSearchGroup[];
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
@@ -1033,6 +1062,8 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
}
export const api = {
globalSearch: (query: string, signal?: AbortSignal) =>
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
languages: () => getJson<Language[]>('/api/languages'),
projectUpdates: (params: ProjectUpdatesParams = {}) =>
getJson<ProjectUpdates>(

View File

@@ -159,6 +159,190 @@ svg {
flex: 1 1 auto;
}
.site-topbar__search {
flex: 0 1 520px;
}
.global-search {
position: relative;
min-width: 220px;
}
.global-search__toggle {
display: none;
}
.global-search__form {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
box-shadow: 0 3px 0 var(--line-strong);
padding: 0 10px;
transition:
border-color 0.14s ease,
box-shadow 0.14s ease;
}
.global-search__form:focus-within {
border-color: var(--pokemon-blue);
box-shadow: 0 3px 0 var(--pokemon-blue-deep);
}
.global-search__form-icon {
width: 20px;
height: 20px;
color: var(--muted);
}
.global-search__input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--ink);
font-size: 0.94rem;
font-weight: 700;
}
.global-search__input::placeholder {
color: var(--muted);
opacity: 1;
}
.global-search__clear {
width: 30px;
min-width: 30px;
min-height: 30px;
display: inline-grid;
place-items: center;
border-radius: var(--radius-small);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.global-search__clear:hover {
background: var(--surface-soft);
color: var(--ink-soft);
}
.global-search__panel {
position: absolute;
inset: calc(100% + 8px) 0 auto 0;
z-index: 80;
max-height: min(70dvh, 620px);
overflow: auto;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-raised);
padding: 10px;
}
.global-search__group + .global-search__group {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.global-search__group-title {
margin: 0 0 6px;
color: var(--muted);
font-size: 0.72rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.global-search__result {
min-height: 58px;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--radius-control);
color: var(--ink);
}
.global-search__result:hover {
background: var(--surface-soft);
}
.global-search__result-image,
.global-search__result-mark {
width: 40px;
height: 40px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
}
.global-search__result-image {
object-fit: contain;
}
.global-search__result-mark {
display: inline-grid;
place-items: center;
color: var(--muted);
}
.global-search__result-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.global-search__result-title,
.global-search__result-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.global-search__result-title {
color: var(--ink);
font-size: 0.94rem;
font-weight: 900;
}
.global-search__result-meta {
display: flex;
gap: 8px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
}
.global-search__message {
margin: 0;
padding: 14px 10px;
color: var(--muted);
font-size: 0.9rem;
font-weight: 800;
text-align: center;
}
.global-search__skeleton {
display: grid;
gap: 8px;
}
.global-search__skeleton span {
height: 48px;
border-radius: var(--radius-control);
background: linear-gradient(90deg, var(--surface-soft), var(--line), var(--surface-soft));
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
.topbar-actions {
min-width: 0;
display: flex;
@@ -6864,6 +7048,53 @@ button:disabled,
display: none;
}
.site-topbar__search {
flex: 0 0 auto;
min-width: 0;
}
.global-search {
position: static;
min-width: 0;
}
.global-search__toggle {
width: 44px;
min-width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
cursor: pointer;
}
.global-search__toggle:hover {
border-color: var(--pokemon-blue);
color: var(--pokemon-blue-deep);
}
.global-search__form {
display: none;
}
.global-search--mobile-open .global-search__form {
position: fixed;
top: 68px;
right: 12px;
left: 12px;
z-index: 80;
display: flex;
}
.global-search__panel {
position: fixed;
inset: 122px 12px auto 12px;
max-height: calc(100dvh - 138px);
}
.topbar-actions {
flex: 0 0 auto;
gap: 6px;

View File

@@ -76,6 +76,23 @@ export const systemWordingMessages = {
logout: 'Log out',
register: 'Register'
},
search: {
label: 'Search Pokopia Wiki',
placeholder: 'Search wiki',
open: 'Open search',
clear: 'Clear search',
empty: 'No matching results',
failed: 'Search is unavailable',
groups: {
pokemon: 'Pokemon',
habitats: 'Habitats',
items: 'Items',
ancientArtifacts: 'Ancient Artifacts',
recipes: 'Recipes',
dailyChecklist: 'Daily CheckList',
life: 'Life'
}
},
notifications: {
title: 'Notifications',
open: 'Open notifications',
@@ -1362,6 +1379,23 @@ export const systemWordingMessages = {
logout: '退出',
register: '注册'
},
search: {
label: '搜索 Pokopia Wiki',
placeholder: '搜索 Wiki',
open: '打开搜索',
clear: '清空搜索',
empty: '没有匹配结果',
failed: '搜索暂不可用',
groups: {
pokemon: 'Pokemon',
habitats: '栖息地',
items: '物品',
ancientArtifacts: 'Ancient Artifacts',
recipes: '材料单',
dailyChecklist: '每日 CheckList',
life: 'Life'
}
},
notifications: {
title: '通知',
open: '打开通知',