diff --git a/docs/progress/current.md b/docs/progress/current.md index 52067ee..80ef68f 100644 --- a/docs/progress/current.md +++ b/docs/progress/current.md @@ -1,8 +1,8 @@ # 宇之然 AI 平台 - 进度追踪 -**文档版本:** v1.2.0 +**文档版本:** v1.3.0 **最后更新:** 2026-07-11 -**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;付费订阅/付费 Skill/用量包已下线 +**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;详情页正文已服务端渲染(SEO 盲点已解决);付费订阅/付费 Skill/用量包已下线 **负责人:** Hermes Agent --- @@ -59,7 +59,19 @@ | OG 分享图 | 新增 `app/opengraph-image.tsx` | 用 `next/og` 在构建期生成 1200x630 分享图(Latin 文案,规避 CJK 字体缺失),替换原缺失的 `/images/og-image.png` | | 工具页 | 增强 `SoftwareApplication` JSON-LD | 补充 publisher / offers(免费) / operatingSystem | -> **已知最大盲点(待办):** `courses/[id]`、`skills/[id]`、`contents/[id]`、`practices/[id]` 的**正文内容仍在客户端 `useEffect` 拉取**,静态 HTML 中无正文,百度等弱 JS 渲染的搜索引擎抓不到实质内容(`tools/[slug]` 已是服务端渲染,正常)。需将详情页改为「服务端取数 → 作为 `initialData` 传入客户端组件」以把正文写入静态 HTML。 +> **已知最大盲点(已解决):** 原 `courses/[id]`、`skills/[id]`、`contents/[id]`、`practices/[id]` 正文在客户端 `useEffect` 拉取,静态 HTML 无正文。已于 v1.3.0 改为「服务端 `getX()` 取数 → 作为 `initialData` 传入客户端组件」,正文(含文章渲染)写入静态 HTML,百度等弱 JS 渲染引擎可正常收录。 + +### 详情页服务端渲染(解决 SEO 盲点)— v1.3.0 + +| 项目 | 改动 | 说明 | +|------|------|------| +| courses/[id] | `page.tsx` 服务端 `getCourse()` + `client.tsx` 接收 `initialCourse` | 课程正文/章节服务端取数,静态 HTML 含实质内容 | +| skills/[id] | `page.tsx` 服务端 `getSkill()` + `client.tsx` 接收 `initialSkill` | 技能描述/systemPrompt 写入静态 HTML | +| contents/[id] | `page.tsx` 服务端 `getContent()` + `client.tsx` 用 `useMemo` 同步渲染 markdown | 文章正文由 `useEffect+setState` 改为同步输出,进入静态 HTML | +| practices/[id] | `page.tsx` 服务端 `getPractice()` + `client.tsx` 接收 `initialQuestion` | 练习题干写入静态 HTML | +| generateStaticParams | 四页均改为从 API 取真实 id 列表 | 课程/技能/文章/练习全部按真实数据生成静态页(技能/练习原硬编码 id 会导致 404 错误体被当作有效数据) | +| 取数健壮性 | 四个 `getX()` 均校验 `data.id` | 后端对缺失资源返回 `HTTP 200 + {error}`(非 404),须据此判定缺失并 `return null`,避免客户端对 undefined 数组 `.map` 报错 | +| 构建验证 | `npm run build` | EXIT=0,216 个 HTML 页面成功导出,技能/课程/文章/练习静态 HTML 实测含正文与 JSON-LD | --- @@ -91,3 +103,4 @@ | 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 | | 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 | | 2026-07-11 | v1.2.0 | Hermes Agent | 落地打赏(个人码捐赠)模式:Donation 模型+迁移+API+前端 /donate 页;会员/技能广场/用量包三处付费面改造为免费+赞助;导航页脚入口;i18n 中文化 | +| 2026-07-11 | v1.3.0 | Hermes Agent | 解决 SEO 盲点:课程/技能/文章/练习四详情页改为服务端取数 + initialData 渲染,正文写入静态 HTML;generateStaticParams 取真实 id;getX 校验 data.id 防 200 错误体;构建通过 | diff --git a/frontend/src/app/contents/[id]/client.tsx b/frontend/src/app/contents/[id]/client.tsx index 343a207..6c0a9f5 100755 --- a/frontend/src/app/contents/[id]/client.tsx +++ b/frontend/src/app/contents/[id]/client.tsx @@ -26,53 +26,57 @@ function truncate(text: string, max = 150): string { return text.length > max ? text.substring(0, max) + '...' : text; } -export default function ContentDetailClient() { +export default function ContentDetailClient({ initialContent }: { initialContent?: Content | null }) { const params = useParams(); - const [content, setContent] = useState(null); - const [loading, setLoading] = useState(true); + const [content, setContent] = useState(initialContent ?? null); + const [loading, setLoading] = useState(!initialContent); const [error, setError] = useState(''); const [related, setRelated] = useState([]); useEffect(() => { + if (initialContent) return; if (!params.id) return; setLoading(true); setError(''); - Promise.all([ - fetch(`${API_BASE}/contents/${params.id}`).then(r => { + fetch(`${API_BASE}/contents/${params.id}`) + .then((r) => { if (!r.ok) throw new Error('内容不存在'); return r.json(); - }), - // Fetch related articles from same category - fetch(`${API_BASE}/contents?pageSize=5`).then(r => r.json()).then(d => d.items || []).catch(() => []), - ]).then(([data, items]: [Content, Content[]]) => { - if (!data || !data.id) throw new Error('内容不存在'); - setContent(data); - // Filter related: same category, exclude current, exclude AI-generated duplicates - const currentCat = data.category?.id; - const currentId = data.id; - const filtered = items - .filter((i: Content) => i.id !== currentId) - .filter((i: Content) => !currentCat || (i.category?.id === currentCat)) - .slice(0, 3); - setRelated(filtered); - }) - .catch(e => setError(e.message)) - .finally(() => setLoading(false)); - }, [params.id]); - - // Render markdown content with enhanced styling - const [renderedHtml, setRenderedHtml] = useState(null); + }) + .then((data) => { + if (!data || !data.id) throw new Error('内容不存在'); + setContent(data); + }) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [params.id, initialContent]); + // Fetch related articles from same category (client-side; not required for SSR body) useEffect(() => { - if (!content?.content) { - setRenderedHtml(null); - return; - } - const result = marked(content.content, { breaks: true, gfm: true }); - if (typeof result === 'string') { - setRenderedHtml(result); - } else { - result.then(html => setRenderedHtml(html)).catch(() => setRenderedHtml(null)); + if (!content?.id) return; + fetch(`${API_BASE}/contents?pageSize=5`) + .then((r) => r.json()) + .then((d) => { + const items: Content[] = d.items || []; + const currentCat = content.category?.id; + const currentId = content.id; + const filtered = items + .filter((i: Content) => i.id !== currentId) + .filter((i: Content) => !currentCat || i.category?.id === currentCat) + .slice(0, 3); + setRelated(filtered); + }) + .catch(() => {}); + }, [content?.id]); + + // Render markdown content synchronously so the body is present in static HTML + const renderedHtml = useMemo(() => { + if (!content?.content) return null; + try { + const result = marked(content.content, { breaks: true, gfm: true }); + return typeof result === 'string' ? result : null; + } catch { + return null; } }, [content?.content]); @@ -127,7 +131,7 @@ export default function ContentDetailClient() { )} {content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'} · - + {content.publishedAt ? new Date(content.publishedAt).toLocaleDateString('zh-CN') : new Date(content.createdAt).toLocaleDateString('zh-CN')} · diff --git a/frontend/src/app/contents/[id]/page.tsx b/frontend/src/app/contents/[id]/page.tsx index 480ebe4..2e44ff2 100755 --- a/frontend/src/app/contents/[id]/page.tsx +++ b/frontend/src/app/contents/[id]/page.tsx @@ -7,7 +7,9 @@ async function getContent(id: string) { try { const res = await fetch(`${API_BASE}/contents/${id}`, { next: { revalidate: 3600 } }); if (!res.ok) return null; - return res.json(); + const data = await res.json(); + if (!data || !data.id) return null; + return data; } catch { return null; } @@ -63,7 +65,7 @@ export default async function ContentDetailPage({ params }: { params: { id: stri {jsonLd && (