From 06e0cbb1c176a9dca6caf7c5bbff78d63a1a2709 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 14:20:12 +0800 Subject: [PATCH] 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 --- DESIGN.md | 2 + backend/src/queries.ts | 198 ++++++++++++++++ backend/src/server.ts | 5 + frontend/src/components/AppShell.vue | 3 + frontend/src/components/GlobalSearch.vue | 280 +++++++++++++++++++++++ frontend/src/services/api.ts | 31 +++ frontend/src/styles/main.css | 231 +++++++++++++++++++ system-wordings.ts | 34 +++ 8 files changed, 784 insertions(+) create mode 100644 frontend/src/components/GlobalSearch.vue diff --git a/DESIGN.md b/DESIGN.md index aef5824..986aa0b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 递增以便后续插入和拖拽排序。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index f9bf426..6d3f00d 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -36,6 +36,31 @@ type DataToolsBundle = { scopes: DataToolScope[]; data: Partial>; }; +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>>; @@ -2411,6 +2436,179 @@ export async function listDailyChecklistItems(locale = defaultLocale) { ); } +export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise { + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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( + ` + 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( diff --git a/backend/src/server.ts b/backend/src/server.ts index bb05ce5..18a4ee9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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, requestLocale(request)) +); + function getBearerToken(authorization: string | undefined): string | null { const [scheme, token] = authorization?.split(' ') ?? []; return scheme === 'Bearer' && token ? token : null; diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 006195f..5587a48 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -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(() => { + +
diff --git a/frontend/src/components/GlobalSearch.vue b/frontend/src/components/GlobalSearch.vue new file mode 100644 index 0000000..f46056c --- /dev/null +++ b/frontend/src/components/GlobalSearch.vue @@ -0,0 +1,280 @@ + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ff1f699..5e0f5df 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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(path: string): Promise { } export const api = { + globalSearch: (query: string, signal?: AbortSignal) => + getJson(`/api/search${buildQuery({ query: query.trim() })}`, signal), languages: () => getJson('/api/languages'), projectUpdates: (params: ProjectUpdatesParams = {}) => getJson( diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 9fc45a3..0681a9b 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; diff --git a/system-wordings.ts b/system-wordings.ts index 39ae8bb..ba829e7 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -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: '打开通知',