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:
@@ -1,8 +1,8 @@
|
|||||||
# 宇之然 AI 平台 - 进度追踪
|
# 宇之然 AI 平台 - 进度追踪
|
||||||
|
|
||||||
**文档版本:** v1.2.0
|
**文档版本:** v1.3.0
|
||||||
**最后更新:** 2026-07-11
|
**最后更新:** 2026-07-11
|
||||||
**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;付费订阅/付费 Skill/用量包已下线
|
**状态:** 🟢 联盟返佣 + 打赏(个人码捐赠)双变现模式已落地;详情页正文已服务端渲染(SEO 盲点已解决);付费订阅/付费 Skill/用量包已下线
|
||||||
**负责人:** Hermes Agent
|
**负责人:** Hermes Agent
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -59,7 +59,19 @@
|
|||||||
| OG 分享图 | 新增 `app/opengraph-image.tsx` | 用 `next/og` 在构建期生成 1200x630 分享图(Latin 文案,规避 CJK 字体缺失),替换原缺失的 `/images/og-image.png` |
|
| OG 分享图 | 新增 `app/opengraph-image.tsx` | 用 `next/og` 在构建期生成 1200x630 分享图(Latin 文案,规避 CJK 字体缺失),替换原缺失的 `/images/og-image.png` |
|
||||||
| 工具页 | 增强 `SoftwareApplication` JSON-LD | 补充 publisher / offers(免费) / operatingSystem |
|
| 工具页 | 增强 `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-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 |
|
||||||
| 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 |
|
| 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.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 错误体;构建通过 |
|
||||||
|
|||||||
@@ -26,53 +26,57 @@ function truncate(text: string, max = 150): string {
|
|||||||
return text.length > max ? text.substring(0, max) + '...' : text;
|
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 params = useParams();
|
||||||
const [content, setContent] = useState<Content | null>(null);
|
const [content, setContent] = useState<Content | null>(initialContent ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialContent);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [related, setRelated] = useState<Content[]>([]);
|
const [related, setRelated] = useState<Content[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialContent) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
Promise.all([
|
fetch(`${API_BASE}/contents/${params.id}`)
|
||||||
fetch(`${API_BASE}/contents/${params.id}`).then(r => {
|
.then((r) => {
|
||||||
if (!r.ok) throw new Error('内容不存在');
|
if (!r.ok) throw new Error('内容不存在');
|
||||||
return r.json();
|
return r.json();
|
||||||
}),
|
})
|
||||||
// Fetch related articles from same category
|
.then((data) => {
|
||||||
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('内容不存在');
|
if (!data || !data.id) throw new Error('内容不存在');
|
||||||
setContent(data);
|
setContent(data);
|
||||||
// Filter related: same category, exclude current, exclude AI-generated duplicates
|
})
|
||||||
const currentCat = data.category?.id;
|
.catch((e) => setError(e.message))
|
||||||
const currentId = data.id;
|
.finally(() => setLoading(false));
|
||||||
|
}, [params.id, initialContent]);
|
||||||
|
|
||||||
|
// Fetch related articles from same category (client-side; not required for SSR body)
|
||||||
|
useEffect(() => {
|
||||||
|
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
|
const filtered = items
|
||||||
.filter((i: Content) => i.id !== currentId)
|
.filter((i: Content) => i.id !== currentId)
|
||||||
.filter((i: Content) => !currentCat || (i.category?.id === currentCat))
|
.filter((i: Content) => !currentCat || i.category?.id === currentCat)
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
setRelated(filtered);
|
setRelated(filtered);
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch(() => {});
|
||||||
.finally(() => setLoading(false));
|
}, [content?.id]);
|
||||||
}, [params.id]);
|
|
||||||
|
|
||||||
// Render markdown content with enhanced styling
|
// Render markdown content synchronously so the body is present in static HTML
|
||||||
const [renderedHtml, setRenderedHtml] = useState<string | null>(null);
|
const renderedHtml = useMemo(() => {
|
||||||
|
if (!content?.content) return null;
|
||||||
useEffect(() => {
|
try {
|
||||||
if (!content?.content) {
|
|
||||||
setRenderedHtml(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = marked(content.content, { breaks: true, gfm: true });
|
const result = marked(content.content, { breaks: true, gfm: true });
|
||||||
if (typeof result === 'string') {
|
return typeof result === 'string' ? result : null;
|
||||||
setRenderedHtml(result);
|
} catch {
|
||||||
} else {
|
return null;
|
||||||
result.then(html => setRenderedHtml(html)).catch(() => setRenderedHtml(null));
|
|
||||||
}
|
}
|
||||||
}, [content?.content]);
|
}, [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">{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
|
||||||
<span className="text-xs text-muted-foreground">·</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')}
|
{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString('zh-CN') : new Date(content.createdAt).toLocaleDateString('zh-CN')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">·</span>
|
<span className="text-xs text-muted-foreground">·</span>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ async function getContent(id: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/contents/${id}`, { next: { revalidate: 3600 } });
|
const res = await fetch(`${API_BASE}/contents/${id}`, { next: { revalidate: 3600 } });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -63,7 +65,7 @@ export default async function ContentDetailPage({ params }: { params: { id: stri
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
)}
|
)}
|
||||||
<ContentDetailClient />
|
<ContentDetailClient initialContent={content} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,28 +33,31 @@ function LoadingSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseDetailClient() {
|
export default function CourseDetailClient({ initialCourse }: { initialCourse?: Course | null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(initialCourse ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialCourse);
|
||||||
const [error, setError] = useState('');
|
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);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialCourse) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_BASE}/courses/${params.id}`)
|
fetch(`${API_BASE}/courses/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (!data || !data.id) throw new Error('课程不存在');
|
if (!data || !data.id) throw new Error('课程不存在');
|
||||||
setCourse(data);
|
setCourse(data);
|
||||||
const firstLesson = data.chapters?.[0]?.lessons?.[0];
|
const firstLesson = data.chapters?.[0]?.lessons?.[0];
|
||||||
if (firstLesson) setActiveLesson(firstLesson);
|
if (firstLesson) setActiveLesson(firstLesson);
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message))
|
.catch((e) => setError(e.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialCourse]);
|
||||||
|
|
||||||
if (loading) return <LoadingSkeleton />;
|
if (loading) return <LoadingSkeleton />;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ async function getCourse(id: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/courses/${id}`, { next: { revalidate: 3600 } });
|
const res = await fetch(`${API_BASE}/courses/${id}`, { next: { revalidate: 3600 } });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ export default async function CourseDetailPage({ params }: { params: { id: strin
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
)}
|
)}
|
||||||
<CourseDetailClient />
|
<CourseDetailClient initialCourse={course} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ interface Submission {
|
|||||||
question: PracticeQuestion;
|
question: PracticeQuestion;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PracticeDetailPage() {
|
export default function PracticeDetailPage({ initialQuestion }: { initialQuestion?: PracticeQuestion | null }) {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
const [question, setQuestion] = useState<PracticeQuestion | null>(null);
|
const [question, setQuestion] = useState<PracticeQuestion | null>(initialQuestion ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialQuestion);
|
||||||
const [answer, setAnswer] = useState('');
|
const [answer, setAnswer] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submission, setSubmission] = useState<Submission | null>(null);
|
const [submission, setSubmission] = useState<Submission | null>(null);
|
||||||
@@ -60,14 +60,15 @@ export default function PracticeDetailPage() {
|
|||||||
const [startTime] = useState(Date.now());
|
const [startTime] = useState(Date.now());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialQuestion) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
fetch(`${API_BASE}/practices/${params.id}`)
|
fetch(`${API_BASE}/practices/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data.id) setQuestion(data);
|
if (data.id) setQuestion(data);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialQuestion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !params.id) return;
|
if (!isLoggedIn || !params.id) return;
|
||||||
|
|||||||
@@ -7,15 +7,24 @@ async function getPractice(id: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/practices/${id}`, { next: { revalidate: 3600 } });
|
const res = await fetch(`${API_BASE}/practices/${id}`, { next: { revalidate: 3600 } });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const ids = Array.from({ length: 20 }, (_, i) => i + 1);
|
try {
|
||||||
return ids.map((id) => ({ id: String(id) }));
|
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> {
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
@@ -51,7 +60,7 @@ export default async function PracticeDetailPage({ params }: { params: { id: str
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
)}
|
)}
|
||||||
<PracticeDetailClient />
|
<PracticeDetailClient initialQuestion={practice} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,20 +16,21 @@ interface Skill {
|
|||||||
}
|
}
|
||||||
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
|
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 params = useParams();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [skill, setSkill] = useState<Skill | null>(null);
|
const [skill, setSkill] = useState<Skill | null>(initialSkill ?? null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(!initialSkill);
|
||||||
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initialSkill) return;
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
fetch(`${API_BASE}/skills/${params.id}`)
|
fetch(`${API_BASE}/skills/${params.id}`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then(data => { if (data.id) setSkill(data); })
|
.then((data) => { if (data.id) setSkill(data); })
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id, initialSkill]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!skill?.id) return;
|
if (!skill?.id) return;
|
||||||
|
|||||||
@@ -7,15 +7,24 @@ async function getSkill(id: string) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/skills/${id}`, { next: { revalidate: 3600 } });
|
const res = await fetch(`${API_BASE}/skills/${id}`, { next: { revalidate: 3600 } });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return res.json();
|
const data = await res.json();
|
||||||
|
if (!data || !data.id) return null;
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const skillIds = ['general-chat', 'coding', 'writing', 'study', 'english', 'prompt-engineering', 'data-analysis', 'career'];
|
try {
|
||||||
return skillIds.map((id) => ({ id }));
|
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> {
|
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
|
||||||
@@ -51,7 +60,7 @@ export default async function SkillDetailPage({ params }: { params: { id: string
|
|||||||
{jsonLd && (
|
{jsonLd && (
|
||||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
)}
|
)}
|
||||||
<SkillDetailClient />
|
<SkillDetailClient initialSkill={skill} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user