fix(seo): 详情页服务端渲染写入静态HTML并修复skills预渲染报错

- courses/skills/contents/practices 详情页改为服务端取数 + initialData 渲染,正文进入静态 HTML(解决百度弱 JS 收录盲点)
- contents 的 markdown 由 useEffect+setState 改为 useMemo 同步输出
- 四个 generateStaticParams 改用 API 真实 id;getX 校验 data.id 防后端 200+error 体被当有效数据导致 undefined.map 报错
- 更新进度文档至 v1.3.0
This commit is contained in:
TradeMate Dev
2026-07-11 17:46:31 +08:00
parent 09bf329544
commit 6850ceff56
9 changed files with 117 additions and 73 deletions
+40 -36
View File
@@ -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<Content | null>(null);
const [loading, setLoading] = useState(true);
const [content, setContent] = useState<Content | null>(initialContent ?? null);
const [loading, setLoading] = useState(!initialContent);
const [error, setError] = useState('');
const [related, setRelated] = useState<Content[]>([]);
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<string | null>(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() {
)}
<span className="text-xs text-muted-foreground">{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground" suppressHydrationWarning>
{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString('zh-CN') : new Date(content.createdAt).toLocaleDateString('zh-CN')}
</span>
<span className="text-xs text-muted-foreground">·</span>
+4 -2
View File
@@ -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 && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
)}
<ContentDetailClient />
<ContentDetailClient initialContent={content} />
</>
);
}
+11 -8
View File
@@ -33,28 +33,31 @@ function LoadingSkeleton() {
);
}
export default function CourseDetailClient() {
export default function CourseDetailClient({ initialCourse }: { initialCourse?: Course | null }) {
const params = useParams();
const [course, setCourse] = useState<Course | null>(null);
const [loading, setLoading] = useState(true);
const [course, setCourse] = useState<Course | null>(initialCourse ?? null);
const [loading, setLoading] = useState(!initialCourse);
const [error, setError] = useState('');
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
const [activeLesson, setActiveLesson] = useState<Lesson | null>(
initialCourse?.chapters?.[0]?.lessons?.[0] ?? null,
);
const [sidebarOpen, setSidebarOpen] = useState(true);
useEffect(() => {
if (initialCourse) return;
if (!params.id) return;
setLoading(true);
fetch(`${API_BASE}/courses/${params.id}`)
.then(r => r.json())
.then(data => {
.then((r) => r.json())
.then((data) => {
if (!data || !data.id) throw new Error('课程不存在');
setCourse(data);
const firstLesson = data.chapters?.[0]?.lessons?.[0];
if (firstLesson) setActiveLesson(firstLesson);
})
.catch(e => setError(e.message))
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [params.id]);
}, [params.id, initialCourse]);
if (loading) return <LoadingSkeleton />;
+4 -2
View File
@@ -7,7 +7,9 @@ async function getCourse(id: string) {
try {
const res = await fetch(`${API_BASE}/courses/${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;
}
@@ -67,7 +69,7 @@ export default async function CourseDetailPage({ params }: { params: { id: strin
{jsonLd && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
)}
<CourseDetailClient />
<CourseDetailClient initialCourse={course} />
</>
);
}
+7 -6
View File
@@ -47,12 +47,12 @@ interface Submission {
question: PracticeQuestion;
}
export default function PracticeDetailPage() {
export default function PracticeDetailPage({ initialQuestion }: { initialQuestion?: PracticeQuestion | null }) {
const params = useParams();
const t = useT();
const { isLoggedIn } = useAuth();
const [question, setQuestion] = useState<PracticeQuestion | null>(null);
const [loading, setLoading] = useState(true);
const [question, setQuestion] = useState<PracticeQuestion | null>(initialQuestion ?? null);
const [loading, setLoading] = useState(!initialQuestion);
const [answer, setAnswer] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submission, setSubmission] = useState<Submission | null>(null);
@@ -60,14 +60,15 @@ export default function PracticeDetailPage() {
const [startTime] = useState(Date.now());
useEffect(() => {
if (initialQuestion) return;
if (!params.id) return;
fetch(`${API_BASE}/practices/${params.id}`)
.then(r => r.json())
.then(data => {
.then((r) => r.json())
.then((data) => {
if (data.id) setQuestion(data);
})
.finally(() => setLoading(false));
}, [params.id]);
}, [params.id, initialQuestion]);
useEffect(() => {
if (!isLoggedIn || !params.id) return;
+14 -5
View File
@@ -7,15 +7,24 @@ async function getPractice(id: string) {
try {
const res = await fetch(`${API_BASE}/practices/${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;
}
}
export function generateStaticParams() {
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
return ids.map((id) => ({ id: String(id) }));
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/practices?pageSize=200`, { next: { revalidate: 3600 } });
if (!res.ok) return [];
const data = await res.json();
const items = data.items || [];
return items.map((p: { id: number | string }) => ({ id: String(p.id) }));
} catch {
return [];
}
}
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
@@ -51,7 +60,7 @@ export default async function PracticeDetailPage({ params }: { params: { id: str
{jsonLd && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
)}
<PracticeDetailClient />
<PracticeDetailClient initialQuestion={practice} />
</>
);
}
+7 -6
View File
@@ -16,20 +16,21 @@ interface Skill {
}
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
export default function SkillDetailPage() {
export default function SkillDetailPage({ initialSkill }: { initialSkill?: Skill | null }) {
const params = useParams();
const t = useT();
const [skill, setSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true);
const [skill, setSkill] = useState<Skill | null>(initialSkill ?? null);
const [loading, setLoading] = useState(!initialSkill);
const [links, setLinks] = useState<AffiliateLink[]>([]);
useEffect(() => {
if (initialSkill) return;
if (!params.id) return;
fetch(`${API_BASE}/skills/${params.id}`)
.then(r => r.json())
.then(data => { if (data.id) setSkill(data); })
.then((r) => r.json())
.then((data) => { if (data.id) setSkill(data); })
.finally(() => setLoading(false));
}, [params.id]);
}, [params.id, initialSkill]);
useEffect(() => {
if (!skill?.id) return;
+14 -5
View File
@@ -7,15 +7,24 @@ async function getSkill(id: string) {
try {
const res = await fetch(`${API_BASE}/skills/${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;
}
}
export function generateStaticParams() {
const skillIds = ['general-chat', 'coding', 'writing', 'study', 'english', 'prompt-engineering', 'data-analysis', 'career'];
return skillIds.map((id) => ({ id }));
export async function generateStaticParams() {
try {
const res = await fetch(`${API_BASE}/skills?pageSize=200`, { next: { revalidate: 3600 } });
if (!res.ok) return [];
const data = await res.json();
const list = Array.isArray(data) ? data : data?.items ?? [];
return list.map((s: { id: string }) => ({ id: s.id }));
} catch {
return [];
}
}
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
@@ -51,7 +60,7 @@ export default async function SkillDetailPage({ params }: { params: { id: string
{jsonLd && (
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
)}
<SkillDetailClient />
<SkillDetailClient initialSkill={skill} />
</>
);
}