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:
@@ -9,6 +9,7 @@
|
|||||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||||
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
||||||
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
||||||
|
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList 和公开可见的 Life Post;结果跳转到对应公开详情页或页面锚点。
|
||||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
|
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
|
||||||
- API 只返回业务需要的字段,不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
|
- API 只返回业务需要的字段,不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
|
||||||
|
- 全局搜索 API 只返回公开浏览所需的最小结果字段:结果类型、ID、展示标题、目标 URL、可选摘要和可选图片;不返回编辑审计、权限、审核原因、内部字段或调试信息。
|
||||||
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
||||||
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
||||||
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ type DataToolsBundle = {
|
|||||||
scopes: DataToolScope[];
|
scopes: DataToolScope[];
|
||||||
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
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 TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||||
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
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) {
|
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
||||||
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
||||||
return queryOne(
|
return queryOne(
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import {
|
|||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
@@ -219,6 +220,10 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
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 {
|
function getBearerToken(authorization: string | undefined): string | null {
|
||||||
const [scheme, token] = authorization?.split(' ') ?? [];
|
const [scheme, token] = authorization?.split(' ') ?? [];
|
||||||
return scheme === 'Bearer' && token ? token : null;
|
return scheme === 'Bearer' && token ? token : null;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AppIcon
|
type AppIcon
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import type { AuthUser, Language } from '../services/api';
|
import type { AuthUser, Language } from '../services/api';
|
||||||
|
import GlobalSearch from './GlobalSearch.vue';
|
||||||
import NotificationBell from './NotificationBell.vue';
|
import NotificationBell from './NotificationBell.vue';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
@@ -271,6 +272,8 @@ onBeforeUnmount(() => {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
|
||||||
|
|
||||||
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
|||||||
280
frontend/src/components/GlobalSearch.vue
Normal file
280
frontend/src/components/GlobalSearch.vue
Normal 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>
|
||||||
@@ -318,6 +318,35 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
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 type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
|
|
||||||
export interface DataToolScopeSummary {
|
export interface DataToolScopeSummary {
|
||||||
@@ -1033,6 +1062,8 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
globalSearch: (query: string, signal?: AbortSignal) =>
|
||||||
|
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||||
getJson<ProjectUpdates>(
|
getJson<ProjectUpdates>(
|
||||||
|
|||||||
@@ -159,6 +159,190 @@ svg {
|
|||||||
flex: 1 1 auto;
|
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 {
|
.topbar-actions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6864,6 +7048,53 @@ button:disabled,
|
|||||||
display: none;
|
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 {
|
.topbar-actions {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ export const systemWordingMessages = {
|
|||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
register: 'Register'
|
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: {
|
notifications: {
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
open: 'Open notifications',
|
open: 'Open notifications',
|
||||||
@@ -1362,6 +1379,23 @@ export const systemWordingMessages = {
|
|||||||
logout: '退出',
|
logout: '退出',
|
||||||
register: '注册'
|
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: {
|
notifications: {
|
||||||
title: '通知',
|
title: '通知',
|
||||||
open: '打开通知',
|
open: '打开通知',
|
||||||
|
|||||||
Reference in New Issue
Block a user