From 4a7309027a4258ffbd48ac58894487fda725821b Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 7 May 2026 13:46:08 +0800 Subject: [PATCH] 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 --- DESIGN.md | 7 ++- frontend/pages/threads/[id].vue | 8 +++- frontend/pages/threads/index.vue | 3 +- frontend/plugins/03-detail-seo.server.ts | 7 ++- frontend/server/utils/seo-files.ts | 1 + frontend/src/seo.ts | 58 ++++++++++++++++++++++-- frontend/src/views/ThreadsView.vue | 10 ++++ system-wordings.ts | 6 +++ 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index c8a5779..4e22262 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1174,12 +1174,15 @@ API 暴露边界: - `/checklist` - `/life` - `/life/:id` + - `/threads` + - `/threads/:threadId` - `/project-updates` -- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。 +- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页、Thread 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。 - Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。 +- Threads 列表页使用 `/threads` canonical 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。 - 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。 - 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。 -- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。 +- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息、未审核 Thread Message、审核原因或实现说明。 - 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。 ## 部署与升级维护 diff --git a/frontend/pages/threads/[id].vue b/frontend/pages/threads/[id].vue index c0dfdc2..616e283 100644 --- a/frontend/pages/threads/[id].vue +++ b/frontend/pages/threads/[id].vue @@ -1,8 +1,14 @@ diff --git a/frontend/pages/threads/index.vue b/frontend/pages/threads/index.vue index c0dfdc2..8674502 100644 --- a/frontend/pages/threads/index.vue +++ b/frontend/pages/threads/index.vue @@ -2,7 +2,8 @@ import ThreadsView from '../../src/views/ThreadsView.vue'; definePageMeta({ - title: 'Threads' + name: 'threads', + seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' } }); diff --git a/frontend/plugins/03-detail-seo.server.ts b/frontend/plugins/03-detail-seo.server.ts index 192cf0c..ac2d7d8 100644 --- a/frontend/plugins/03-detail-seo.server.ts +++ b/frontend/plugins/03-detail-seo.server.ts @@ -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'; export default defineNuxtPlugin(async () => { @@ -68,6 +68,11 @@ async function detailSeo( image: recipe.item.image?.url }; } + + if (routeName === 'thread-detail') { + const thread = await api.thread(routeId); + return threadSeoConfig(thread, t); + } } catch { return null; } diff --git a/frontend/server/utils/seo-files.ts b/frontend/server/utils/seo-files.ts index 6b954df..062fedc 100644 --- a/frontend/server/utils/seo-files.ts +++ b/frontend/server/utils/seo-files.ts @@ -13,6 +13,7 @@ const sitemapPaths = [ '/dish', '/checklist', '/life', + '/threads', '/project-updates', '/privacy-policy', '/terms-of-service', diff --git a/frontend/src/seo.ts b/frontend/src/seo.ts index 166859a..b2d61f2 100644 --- a/frontend/src/seo.ts +++ b/frontend/src/seo.ts @@ -9,7 +9,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; let runtimeSiteUrl: string | null = null; type TranslationValues = Record; -type Translator = (key: string, values?: TranslationValues) => string; +export type Translator = (key: string, values?: TranslationValues) => string; export type RouteSeoConfig = { title?: string; @@ -27,6 +27,8 @@ export type SeoConfig = { canonicalPath?: string; image?: string | null; noindex?: boolean; + openGraphType?: 'website' | 'article'; + structuredData?: Record; }; export type ResolvedSeoConfig = { @@ -36,9 +38,21 @@ export type ResolvedSeoConfig = { imageUrl: string; robots: string; locale: string; + openGraphType: 'website' | 'article'; structuredData: Record; }; +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; let activeTranslator: Translator | 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 robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow'; const locale = getCurrentLocale(); + const openGraphType = config.openGraphType ?? 'website'; return { title, @@ -128,7 +143,8 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig { imageUrl, robots, locale, - structuredData: { + openGraphType, + structuredData: config.structuredData ?? { '@context': 'https://schema.org', '@type': 'WebPage', name: title, @@ -157,7 +173,7 @@ export function resolvedSeoHead(seo: ResolvedSeoConfig) { { key: 'twitter-description', name: 'twitter:description', content: seo.description }, { key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl }, { 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-description', property: 'og:description', content: seo.description }, { 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 { 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') + } + } + }; +} diff --git a/frontend/src/views/ThreadsView.vue b/frontend/src/views/ThreadsView.vue index 45fd66b..8554ddd 100644 --- a/frontend/src/views/ThreadsView.vue +++ b/frontend/src/views/ThreadsView.vue @@ -22,6 +22,7 @@ import { iconThreads, iconUndo } from '../icons'; +import { applySeo, resolvedSeoHead, resolveSeo, threadSeoConfig } from '../seo'; import { api, threadWebSocketUrl, @@ -158,6 +159,9 @@ const currentThreadList = computed(() => { }); }); 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(() => { const groups: MessageGroup[] = []; @@ -826,6 +830,12 @@ watch(activeThreadId, async () => { await loadMessages(true); }); +watch(activeThread, (thread) => { + if (thread) { + applySeo(threadSeoConfig(thread, t)); + } +}); + onMounted(() => { void loadAll(); }); diff --git a/system-wordings.ts b/system-wordings.ts index 65449ad..8d9367b 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -133,6 +133,10 @@ export const systemWordingMessages = { seo: { 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.', + 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: 'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.', itemDetailDescription: @@ -1587,6 +1591,8 @@ export const systemWordingMessages = { }, seo: { 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 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。', itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。', ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',