feat(threads): add SEO metadata, sitemap, and structured data

Include /threads in sitemap and set canonical paths
Generate DiscussionForumPosting structured data for thread details
Add dynamic SEO updates for thread navigation and server-side rendering
This commit is contained in:
2026-05-07 13:46:08 +08:00
parent 520d988589
commit 4a7309027a
8 changed files with 92 additions and 8 deletions

View File

@@ -1174,12 +1174,15 @@ API 暴露边界:
- `/checklist` - `/checklist`
- `/life` - `/life`
- `/life/:id` - `/life/:id`
- `/threads`
- `/threads/:threadId`
- `/project-updates` - `/project-updates`
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口实体详情页、Life Post 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。 - `sitemap.xml` 当前只包含稳定的公开顶层浏览入口实体详情页、Life Post 详情页、Thread 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。 - Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
- Threads 列表页使用 `/threads` canonical 并进入 sitemapThread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。 - 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。 - 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。 - SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息、未审核 Thread Message、审核原因或实现说明。
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL因此暂不输出 `hreflang` - 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL因此暂不输出 `hreflang`
## 部署与升级维护 ## 部署与升级维护

View File

@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import ThreadsView from '../../src/views/ThreadsView.vue'; import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({ definePageMeta({
title: 'Threads' name: 'thread-detail',
seo: {
titleKey: 'pages.threads.title',
descriptionKey: 'seo.threadsDescription',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/threads/${String(route.params.id)}`
}
}); });
</script> </script>

View File

@@ -2,7 +2,8 @@
import ThreadsView from '../../src/views/ThreadsView.vue'; import ThreadsView from '../../src/views/ThreadsView.vue';
definePageMeta({ definePageMeta({
title: 'Threads' name: 'threads',
seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' }
}); });
</script> </script>

View File

@@ -1,4 +1,4 @@
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo'; import { resolvedSeoHead, resolveSeo, threadSeoConfig, type SeoConfig } from '../src/seo';
import { api } from '../src/services/api'; import { api } from '../src/services/api';
export default defineNuxtPlugin(async () => { export default defineNuxtPlugin(async () => {
@@ -68,6 +68,11 @@ async function detailSeo(
image: recipe.item.image?.url image: recipe.item.image?.url
}; };
} }
if (routeName === 'thread-detail') {
const thread = await api.thread(routeId);
return threadSeoConfig(thread, t);
}
} catch { } catch {
return null; return null;
} }

View File

@@ -13,6 +13,7 @@ const sitemapPaths = [
'/dish', '/dish',
'/checklist', '/checklist',
'/life', '/life',
'/threads',
'/project-updates', '/project-updates',
'/privacy-policy', '/privacy-policy',
'/terms-of-service', '/terms-of-service',

View File

@@ -9,7 +9,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null; let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>; type TranslationValues = Record<string, string | number>;
type Translator = (key: string, values?: TranslationValues) => string; export type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = { export type RouteSeoConfig = {
title?: string; title?: string;
@@ -27,6 +27,8 @@ export type SeoConfig = {
canonicalPath?: string; canonicalPath?: string;
image?: string | null; image?: string | null;
noindex?: boolean; noindex?: boolean;
openGraphType?: 'website' | 'article';
structuredData?: Record<string, unknown>;
}; };
export type ResolvedSeoConfig = { export type ResolvedSeoConfig = {
@@ -36,9 +38,21 @@ export type ResolvedSeoConfig = {
imageUrl: string; imageUrl: string;
robots: string; robots: string;
locale: string; locale: string;
openGraphType: 'website' | 'article';
structuredData: Record<string, unknown>; structuredData: Record<string, unknown>;
}; };
export type ThreadSeoSummary = {
id: number;
title: string;
languageCode: string;
tags: Array<{ name: string }>;
messageCount: number;
createdAt: string;
lastActiveAt: string;
author: { displayName: string } | null;
};
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>; const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null; let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null; let currentSeo: ResolvedSeoConfig | null = null;
@@ -120,6 +134,7 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath); const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow'; const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale(); const locale = getCurrentLocale();
const openGraphType = config.openGraphType ?? 'website';
return { return {
title, title,
@@ -128,7 +143,8 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
imageUrl, imageUrl,
robots, robots,
locale, locale,
structuredData: { openGraphType,
structuredData: config.structuredData ?? {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebPage', '@type': 'WebPage',
name: title, name: title,
@@ -157,7 +173,7 @@ export function resolvedSeoHead(seo: ResolvedSeoConfig) {
{ key: 'twitter-description', name: 'twitter:description', content: seo.description }, { key: 'twitter-description', name: 'twitter:description', content: seo.description },
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl }, { key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: siteName }, { key: 'og-site-name', property: 'og:site_name', content: siteName },
{ key: 'og-type', property: 'og:type', content: 'website' }, { key: 'og-type', property: 'og:type', content: seo.openGraphType },
{ key: 'og-title', property: 'og:title', content: seo.title }, { key: 'og-title', property: 'og:title', content: seo.title },
{ key: 'og-description', property: 'og:description', content: seo.description }, { key: 'og-description', property: 'og:description', content: seo.description },
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl }, { key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
@@ -219,3 +235,39 @@ export function applySeo(config: SeoConfig = {}): void {
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void { export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
applySeo(routeSeoConfig(route)); applySeo(routeSeoConfig(route));
} }
export function threadSeoConfig(thread: ThreadSeoSummary, translator: Translator): SeoConfig {
const title = thread.title.trim() || translator('pages.threads.title');
const canonicalPath = `/threads/${thread.id}`;
const keywords = thread.tags.map((tag) => tag.name.trim()).filter(Boolean).join(', ');
const description = translator('seo.threadDetailDescription', { title, count: thread.messageCount });
return {
title: `${title} - ${translator('pages.threads.title')}`,
description,
canonicalPath,
openGraphType: 'article',
structuredData: {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
headline: title,
description,
url: absoluteUrl(canonicalPath),
datePublished: thread.createdAt,
dateModified: thread.lastActiveAt,
inLanguage: thread.languageCode,
keywords: keywords || undefined,
author: thread.author ? { '@type': 'Person', name: thread.author.displayName } : undefined,
interactionStatistic: {
'@type': 'InteractionCounter',
interactionType: { '@type': 'CommentAction' },
userInteractionCount: thread.messageCount
},
isPartOf: {
'@type': 'WebPage',
name: translator('pages.threads.title'),
url: absoluteUrl('/threads')
}
}
};
}

View File

@@ -22,6 +22,7 @@ import {
iconThreads, iconThreads,
iconUndo iconUndo
} from '../icons'; } from '../icons';
import { applySeo, resolvedSeoHead, resolveSeo, threadSeoConfig } from '../seo';
import { import {
api, api,
threadWebSocketUrl, threadWebSocketUrl,
@@ -158,6 +159,9 @@ const currentThreadList = computed(() => {
}); });
}); });
const detailModalOpen = computed(() => activeThread.value !== null); const detailModalOpen = computed(() => activeThread.value !== null);
const threadSeo = computed(() => (activeThread.value ? resolveSeo(threadSeoConfig(activeThread.value, t)) : null));
useHead(() => (threadSeo.value ? resolvedSeoHead(threadSeo.value) : {}));
const messageGroups = computed<MessageGroup[]>(() => { const messageGroups = computed<MessageGroup[]>(() => {
const groups: MessageGroup[] = []; const groups: MessageGroup[] = [];
@@ -826,6 +830,12 @@ watch(activeThreadId, async () => {
await loadMessages(true); await loadMessages(true);
}); });
watch(activeThread, (thread) => {
if (thread) {
applySeo(threadSeoConfig(thread, t));
}
});
onMounted(() => { onMounted(() => {
void loadAll(); void loadAll();
}); });

View File

@@ -133,6 +133,10 @@ export const systemWordingMessages = {
seo: { seo: {
siteDescription: siteDescription:
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.', 'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
threadsDescription:
'Browse Pokopia Wiki Threads for Pokemon Pokopia community discussions by channel, language, tags, and recent activity.',
threadDetailDescription:
'Read the {title} community thread in Pokopia Wiki Threads, including public discussion, channel tags, language, and {count} visible messages.',
pokemonDetailDescription: pokemonDetailDescription:
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.', 'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
itemDetailDescription: itemDetailDescription:
@@ -1587,6 +1591,8 @@ export const systemWordingMessages = {
}, },
seo: { seo: {
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。', siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
threadsDescription: '按频道、语言、标签和最近活跃浏览 Pokopia Wiki 讨论,查看 Pokemon Pokopia 社区帖子。',
threadDetailDescription: '查看 Pokopia Wiki 讨论中的 {title} 帖子,包含公开讨论、频道标签、语言和 {count} 条可见消息。',
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。', pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。', itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。', ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',