feat: 联盟返佣系统规范化并打通技能广场(无 ICP 证合规变现主路径)
- 新增 AffiliateProgram / AffiliateLink / AffiliateClick 规范化 Prisma 模型 取代原手写裸表 affiliate_stats / affiliate_clicks - 新增迁移 prisma/migrations/20260711000000_add_affiliate_models - 重构 affiliate.service.ts 改用 Prisma ORM,消除 $queryRawUnsafe SQL 注入 - 重构 affiliate.controller.ts 接口:programs / links(?skillId,?toolId) / stats / click - 前端 affiliate 页接入真实接口,移除硬编码 demo 数据 - 技能详情页新增「学此技能推荐使用的工具」联盟链接区块 - 新增 affiliate.service.spec.ts(4 用例通过)与幂等种子 seed-affiliate.ts - 更新 docs/progress/current.md,明确无 ICP 经营许可证下以联盟返佣为合规变现主路径 - 含此前工作区未提交改动(工具 slug 路由、支付/订单、SEO 等) Co-Authored-By: opencode <opencode@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '关于宇之然',
|
||||
description: '关于宇之然 AI 学习与实践平台',
|
||||
description: '宇之然 AI 致力于让每个人都能用好 AI。北京宇之然科技中心是一家专注于 AI 技术普及与应用的科技企业,提供 AI 工具指南、课程学习、提示词库、AI 沙盒实战等服务。',
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 工具返利联盟 - 赚取佣金',
|
||||
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。我们提供详细的返佣数据追踪,支持多种 AI 工具联盟合作。',
|
||||
keywords: ['AI工具返利', 'AI工具联盟', 'AI工具佣金', 'ChatGPT返利', 'Claude返利', 'Midjourney返利', 'AI工具推广', 'AI联盟计划', 'AI返佣', 'AI工具代理'],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'AI 工具返利联盟 - 赚取佣金',
|
||||
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。',
|
||||
type: 'website',
|
||||
url: 'https://yuzhiran.com/affiliate',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'AI 工具返利联盟',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'AI 工具返利联盟 - 赚取佣金',
|
||||
description: '加入宇之然 AI 工具返利联盟,通过推荐 AI 工具赚取佣金。',
|
||||
images: ['/images/og-image.png'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function AffiliateLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalLink, Link as LinkIcon, DollarSign, ArrowUpRight, BarChart3 } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
interface AffiliateLink {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
thumbnail: string | null;
|
||||
isFeatured: boolean;
|
||||
tags: string | null;
|
||||
program?: { name: string; provider?: string | null };
|
||||
}
|
||||
|
||||
interface AffiliateStats {
|
||||
totalClicks: number;
|
||||
totalRevenue: number;
|
||||
topLinks: { id: number; title: string; clicks: number; estimatedRevenue: number }[];
|
||||
}
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-5 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-1" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AffiliatePage() {
|
||||
const t = useT();
|
||||
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
||||
const [stats, setStats] = useState<AffiliateStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/affiliate/links`).then((r) => r.json()),
|
||||
fetch(`${API_BASE}/affiliate/stats`).then((r) => r.json()),
|
||||
])
|
||||
.then(([linksData, statsData]) => {
|
||||
setLinks(linksData.links || []);
|
||||
setStats(statsData);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handlePromote(link: AffiliateLink) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
window.open(link.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.affiliate.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.affiliate.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t.affiliate.totalClicks}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{(stats?.totalClicks ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center">
|
||||
<ArrowUpRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t.affiliate.totalRevenue}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
¥{(stats?.totalRevenue ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-xl flex items-center justify-center">
|
||||
<DollarSign className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{t.affiliate.partnerTools}</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{links.length}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-xl flex items-center justify-center">
|
||||
<BarChart3 className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.recommendTools}</h2>
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : links.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">{t.affiliate.noData}</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{links.map((link) => (
|
||||
<Card key={link.id} className="p-5 hover:shadow-md transition-all">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0">
|
||||
<LinkIcon className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold">{link.title}</h3>
|
||||
{link.isFeatured && <Badge variant="secondary">推荐</Badge>}
|
||||
</div>
|
||||
{link.program && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{link.program.name}</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{link.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePromote(link)}
|
||||
className="w-full text-center py-2 px-3 bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors text-sm"
|
||||
>
|
||||
{t.affiliate.promoteLink}
|
||||
</button>
|
||||
{link.tags && (
|
||||
<div className="flex gap-1 mt-3 flex-wrap">
|
||||
{link.tags
|
||||
.split(',')
|
||||
.slice(0, 3)
|
||||
.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.topLinks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">{t.affiliate.ranking}</h2>
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-4 text-sm font-medium text-muted-foreground">
|
||||
{t.affiliate.tool}
|
||||
</th>
|
||||
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
|
||||
{t.affiliate.clicks}
|
||||
</th>
|
||||
<th className="text-right p-4 text-sm font-medium text-muted-foreground">
|
||||
{t.affiliate.revenue}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.topLinks.map((item, index) => (
|
||||
<tr key={item.id} className="border-t border-border">
|
||||
<td className="p-4 font-medium">
|
||||
<span className="text-muted-foreground mr-2">#{index + 1}</span>
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="p-4 text-right">{item.clicks.toLocaleString()}</td>
|
||||
<td className="p-4 text-right font-medium text-green-600">
|
||||
¥{item.estimatedRevenue.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-10 text-xs text-muted-foreground text-center max-w-2xl mx-auto">
|
||||
{t.affiliate.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { marked } from 'marked';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Content {
|
||||
id: number; title: string; summary?: string; content?: string; cover?: string;
|
||||
contentType: string; tags?: string; authorName?: string; viewCount: number;
|
||||
isAiGenerated: boolean; publishedAt: string; createdAt: string;
|
||||
category?: { name: string };
|
||||
link?: string; contentType: string; tags?: string; authorName?: string;
|
||||
viewCount: number; isAiGenerated: boolean; publishedAt: string; createdAt: string;
|
||||
category?: { id?: number; name: string };
|
||||
}
|
||||
|
||||
// Calculate estimated reading time
|
||||
function readingTime(text: string): string {
|
||||
const words = text.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().split(/\s+/).length;
|
||||
const minutes = Math.max(1, Math.ceil(words / 200));
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
// Truncate text for preview
|
||||
function truncate(text: string, max = 150): string {
|
||||
return text.length > max ? text.substring(0, max) + '...' : text;
|
||||
}
|
||||
|
||||
export default function ContentDetailClient() {
|
||||
@@ -18,101 +31,247 @@ export default function ContentDetailClient() {
|
||||
const [content, setContent] = useState<Content | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [related, setRelated] = useState<Content[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.id) return;
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/contents/${params.id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data || !data.id) throw new Error('内容不存在');
|
||||
setContent(data);
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
setError('');
|
||||
Promise.all([
|
||||
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);
|
||||
|
||||
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));
|
||||
}
|
||||
}, [content?.content]);
|
||||
|
||||
const readingTimeText = useMemo(() => {
|
||||
if (!content?.content) return '';
|
||||
return readingTime(content.content);
|
||||
}, [content?.content]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-4 w-32 mb-8" />
|
||||
<Skeleton className="h-64 w-full mb-4" />
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-6">
|
||||
<Skeleton className="h-4 w-24 mb-4" />
|
||||
<Skeleton className="h-10 w-3/4 mb-3" />
|
||||
<Skeleton className="h-5 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full mb-6" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error || !content) return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground mb-2">{error || '内容不存在'}</h1>
|
||||
<Link href="/" className="text-brand-600 hover:underline text-sm">返回首页</Link>
|
||||
<Link href="/contents" className="text-brand-600 hover:underline text-sm">返回文章列表</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3">
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
|
||||
<Link href="/contents" className="hover:text-brand-600 transition-colors">文章</Link>
|
||||
<span>›</span>
|
||||
{content.category && <span>{content.category.name}</span>}
|
||||
</div>
|
||||
|
||||
{/* Category badge + meta */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{content.category && (
|
||||
<span className="bg-brand-50 text-brand-600 px-2 py-0.5 rounded">{content.category.name}</span>
|
||||
<span className="bg-brand-600 text-white text-xs font-medium px-2.5 py-0.5 rounded-full">{content.category.name}</span>
|
||||
)}
|
||||
<span>{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
|
||||
<span>{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString() : new Date(content.createdAt).toLocaleDateString()}</span>
|
||||
<span>{content.viewCount} 次阅读</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">
|
||||
{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>
|
||||
<span className="text-xs text-muted-foreground">{readingTimeText}</span>
|
||||
{content.isAiGenerated && (
|
||||
<span className="text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded">AI 生成</span>
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 px-2 py-0.5 rounded-full">AI 生成</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-foreground leading-tight">{content.title}</h1>
|
||||
{content.authorName && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">作者:{content.authorName}</p>
|
||||
)}
|
||||
{content.summary && (
|
||||
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">{content.summary}</p>
|
||||
)}
|
||||
{content.tags && (
|
||||
<div className="flex gap-2 mt-4 flex-wrap">
|
||||
{content.tags.split(',').map(tag => (
|
||||
<span key={tag} className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{tag.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{content.cover && (
|
||||
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
|
||||
{content.cover.startsWith('http') ? (
|
||||
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
|
||||
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground leading-tight mb-4">
|
||||
{content.title}
|
||||
</h1>
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground border-t border-border pt-4">
|
||||
{content.authorName && <span>作者:{content.authorName}</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
{content.viewCount} 次阅读
|
||||
</span>
|
||||
{content.tags && (
|
||||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||
{content.tags.split(',').map(tag => (
|
||||
<span key={tag} className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">{tag.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{content.summary && (
|
||||
<div className="mt-4 p-4 bg-muted/30 dark:bg-muted/20 rounded-xl border border-border">
|
||||
<p className="text-foreground leading-relaxed">
|
||||
<span className="text-brand-600 font-medium mr-2">摘要</span>
|
||||
{content.summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Source card - show if there's a link */}
|
||||
{content.link && (
|
||||
<div className="mb-8 p-4 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20 rounded-xl border border-brand-100 dark:border-brand-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-brand-600 dark:text-brand-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-foreground">📰 来源</span>
|
||||
<a
|
||||
href={content.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-brand-600 dark:text-brand-400 hover:underline font-medium flex items-center gap-1 flex-shrink-0"
|
||||
>
|
||||
阅读全文
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6v6M16 4l4 4" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground break-all">{content.link}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prose prose-gray max-w-none">
|
||||
{content.content ? (
|
||||
<div className="text-foreground leading-relaxed whitespace-pre-wrap text-base">
|
||||
{content.content}
|
||||
</div>
|
||||
{/* Article body - rendered markdown */}
|
||||
{renderedHtml ? (
|
||||
<div className="article-body text-foreground leading-relaxed" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic py-8 text-center">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground italic py-8 text-center">暂无内容</p>
|
||||
)}
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 pt-8 border-t border-border">
|
||||
{/* Tags */}
|
||||
{content.tags && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span className="text-sm font-medium text-muted-foreground">标签:</span>
|
||||
{content.tags.split(',').map(tag => (
|
||||
<span key={tag} className="text-xs text-muted-foreground bg-muted/50 hover:bg-muted transition-colors px-2.5 py-1 rounded-full cursor-default">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disclaimer */}
|
||||
<div className="text-xs text-muted-foreground bg-muted/20 rounded-lg p-3">
|
||||
📝 本文由宇之然AI 每日采集发布,内容整理自原文,仅供参考,版权归原文作者所有。
|
||||
</div>
|
||||
|
||||
{/* Related articles */}
|
||||
{related.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-brand-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
相关文章
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{related.map((item) => (
|
||||
<Link key={item.id} href={`/contents/${item.id}`} className="block group">
|
||||
<div className="p-3 rounded-lg border border-border hover:border-brand-200 hover:shadow-sm transition-all">
|
||||
<h3 className="text-sm font-medium text-foreground group-hover:text-brand-600 transition-colors line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
{item.summary && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{truncate(item.summary, 80)}</p>
|
||||
)}
|
||||
{item.publishedAt && (
|
||||
<span className="text-xs text-muted-foreground mt-2 block">
|
||||
{new Date(item.publishedAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back button */}
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href="/contents" className="inline-flex items-center gap-1 text-sm text-brand-600 hover:text-brand-700 font-medium transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回文章列表
|
||||
</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { FileText, Eye } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
interface Content {
|
||||
id: number; title: string; summary: string | null; cover: string | null;
|
||||
contentType: string; tags: string | null; authorName: string | null;
|
||||
viewCount: number; publishedAt: string; category?: { name: string };
|
||||
viewCount: number; publishedAt: string; createdAt: string;
|
||||
category?: { id?: number; name: string }; content?: string; isAiGenerated?: boolean;
|
||||
}
|
||||
|
||||
function ContentSkeleton() {
|
||||
@@ -29,24 +31,160 @@ function ContentSkeleton() {
|
||||
}
|
||||
|
||||
export default function ContentsPage() {
|
||||
const [pathname, setPathname] = useState('');
|
||||
const [contents, setContents] = useState<Content[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailContent, setDetailContent] = useState<Content | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [renderedHtml, setRenderedHtml] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setPathname(window.location.pathname);
|
||||
}, []);
|
||||
|
||||
// Detect if URL is a detail page like /contents/109
|
||||
const pathParts = pathname.split('/').filter(Boolean);
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
const isDetailView = /^\d+$/.test(lastPart ?? '');
|
||||
const contentId = isDetailView ? lastPart : '';
|
||||
|
||||
// Fetch detail content
|
||||
useEffect(() => {
|
||||
if (!isDetailView || !contentId) {
|
||||
setDetailContent(null);
|
||||
setRenderedHtml(null);
|
||||
setDetailLoading(false);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
setError('');
|
||||
fetch(`${API_BASE}/contents/${contentId}`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('内容不存在');
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data || !data.id) throw new Error('内容不存在');
|
||||
setDetailContent(data);
|
||||
})
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}, [contentId]);
|
||||
|
||||
// Render markdown
|
||||
useEffect(() => {
|
||||
if (!detailContent?.content) {
|
||||
setRenderedHtml(null);
|
||||
return;
|
||||
}
|
||||
const result = marked(detailContent.content, { breaks: true, gfm: true });
|
||||
if (typeof result === 'string') {
|
||||
setRenderedHtml(result);
|
||||
} else {
|
||||
result.then(html => setRenderedHtml(html)).catch(() => setRenderedHtml(null));
|
||||
}
|
||||
}, [detailContent?.content]);
|
||||
|
||||
// Fetch list
|
||||
useEffect(() => {
|
||||
if (isDetailView) return;
|
||||
setLoading(true);
|
||||
fetch(`${API_BASE}/contents`)
|
||||
.then(r => r.json()).then(data => setContents(data.items || []))
|
||||
.catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [pathname]);
|
||||
|
||||
const typeLabels: Record<string, string> = { article: '文章', tutorial: '教程', news: '资讯' };
|
||||
// Show detail view
|
||||
if (isDetailView && contentId) {
|
||||
if (detailLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-4 w-32 mb-8" />
|
||||
<Skeleton className="h-64 w-full mb-4" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error || !detailContent) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground mb-2">{error || '内容不存在'}</h1>
|
||||
<Link href="/contents" className="text-brand-600 hover:underline text-sm">返回文章列表</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3">
|
||||
{detailContent.category && (
|
||||
<span className="bg-brand-50 text-brand-600 px-2 py-0.5 rounded">{detailContent.category.name}</span>
|
||||
)}
|
||||
<span>{detailContent.contentType === 'tutorial' ? '教程' : detailContent.contentType === 'news' ? '资讯' : '文章'}</span>
|
||||
<span>{detailContent.publishedAt ? new Date(detailContent.publishedAt).toLocaleDateString() : new Date(detailContent.createdAt).toLocaleDateString()}</span>
|
||||
<span>{detailContent.viewCount} 次阅读</span>
|
||||
{detailContent.isAiGenerated && (
|
||||
<span className="text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded">AI 生成</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground leading-tight">{detailContent.title}</h1>
|
||||
{detailContent.authorName && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">作者:{detailContent.authorName}</p>
|
||||
)}
|
||||
{detailContent.summary && (
|
||||
<p className="mt-4 text-muted-foreground leading-relaxed">{detailContent.summary}</p>
|
||||
)}
|
||||
{detailContent.tags && (
|
||||
<div className="flex gap-2 mt-4 flex-wrap">
|
||||
{detailContent.tags.split(',').map(tag => (
|
||||
<span key={tag} className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{tag.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{detailContent.cover && (
|
||||
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
|
||||
{detailContent.cover.startsWith('http') ? (
|
||||
<img src={detailContent.cover} alt={detailContent.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
|
||||
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderedHtml ? (
|
||||
<div className="article-body text-foreground leading-relaxed" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
|
||||
) : (
|
||||
<p className="text-muted-foreground italic py-8 text-center">加载中...</p>
|
||||
)}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<Link href="/contents" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
|
||||
← 返回文章列表
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// List view
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-foreground">内容中心</h1>
|
||||
<p className="mt-2 text-muted-foreground">AI 相关的教程、资讯和深度文章</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1,2,3].map(i => <ContentSkeleton key={i} />)}
|
||||
@@ -83,4 +221,4 @@ export default function ContentsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { BookOpen, AlertTriangle, Menu, ChevronLeft, X } from 'lucide-react';
|
||||
|
||||
interface Lesson {
|
||||
id: number; title: string; content?: string; sortOrder: number; status: string;
|
||||
@@ -20,6 +21,51 @@ interface Course {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-4 w-32 mb-8" />
|
||||
<Skeleton className="h-64 w-full mb-4 rounded-xl" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PayModal({ paying, qrCodeUrl, onClose }: { paying: boolean; qrCodeUrl: string; onClose: () => void }) {
|
||||
if (!paying) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-card rounded-2xl p-8 max-w-md w-full text-center shadow-xl border border-border">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground">扫码支付</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-muted">
|
||||
<X className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{qrCodeUrl ? (
|
||||
<>
|
||||
<div className="bg-muted/50 rounded-xl p-6 mb-4 inline-block">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrCodeUrl)}`}
|
||||
alt="支付二维码"
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">请使用微信扫描二维码完成支付</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12">
|
||||
<div className="w-10 h-10 border-2 border-brand-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">正在生成支付二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CourseDetailClient() {
|
||||
const params = useParams();
|
||||
const [course, setCourse] = useState<Course | null>(null);
|
||||
@@ -28,9 +74,7 @@ export default function CourseDetailClient() {
|
||||
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [orderNo, setOrderNo] = useState('');
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
||||
const [payLoading, setPayLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.id) return;
|
||||
@@ -47,22 +91,12 @@ export default function CourseDetailClient() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-4 w-32 mb-8" />
|
||||
<Skeleton className="h-64 w-full mb-4" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
|
||||
if (error || !course) return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div className="w-16 h-16 bg-destructive/10 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-foreground mb-2">{error || '课程不存在'}</h1>
|
||||
<Link href="/courses" className="text-brand-600 hover:underline text-sm">返回课程列表</Link>
|
||||
@@ -71,166 +105,164 @@ export default function CourseDetailClient() {
|
||||
|
||||
const totalLessons = course.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
||||
|
||||
function PayModal() {
|
||||
const [qrUrl, setQrUrl] = useState(qrCodeUrl);
|
||||
useEffect(() => { setQrUrl(qrCodeUrl); }, [qrCodeUrl]);
|
||||
|
||||
if (!paying) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-card rounded-2xl p-8 max-w-md w-full text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">扫码支付</h3>
|
||||
{qrUrl ? (
|
||||
<>
|
||||
<div className="bg-muted/50 rounded-xl p-6 mb-4 inline-block">
|
||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrUrl)}`} alt="支付二维码" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">请使用微信扫描二维码完成支付</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-4">正在生成支付二维码...</p>
|
||||
)}
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => { setPaying(false); setQrCodeUrl(''); }}
|
||||
className="px-4 py-2 border border-border rounded-lg text-sm hover:bg-muted/50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)]">
|
||||
<aside className={`${sidebarOpen ? 'translate-x-0' : '-translate-x-full'} fixed md:relative md:translate-x-0 z-30 w-72 bg-card border-r border-border overflow-y-auto flex-shrink-0 transition-transform`}>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 block">
|
||||
← 返回课程列表
|
||||
</Link>
|
||||
<h2 className="font-semibold text-foreground text-sm line-clamp-2">{course.title}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">{course.chapters.length} 章 · {totalLessons} 课时</p>
|
||||
</div>
|
||||
<nav className="p-2">
|
||||
{course.chapters.map((chapter, ci) => (
|
||||
<div key={chapter.id} className="mb-3">
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
|
||||
{ci + 1}. {chapter.title}
|
||||
</div>
|
||||
{chapter.lessons.map((lesson, li) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => setActiveLesson(lesson)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeLesson?.id === lesson.id
|
||||
? 'bg-brand-50 text-brand-700 font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground mr-2">{ci + 1}.{li + 1}</span>
|
||||
{lesson.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{course.chapters.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">暂无课时内容</p>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
<>
|
||||
<PayModal
|
||||
paying={paying}
|
||||
qrCodeUrl={qrCodeUrl}
|
||||
onClose={() => { setPaying(false); setQrCodeUrl(''); }}
|
||||
/>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
|
||||
>
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-medium text-foreground truncate">
|
||||
{activeLesson?.title || course.title}
|
||||
</h1>
|
||||
<div className="flex h-[calc(100vh-4rem)] max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
fixed md:relative md:translate-x-0 z-30
|
||||
w-72 bg-card border-r border-border
|
||||
overflow-y-auto flex-shrink-0 transition-transform
|
||||
`}>
|
||||
<div className="p-4 border-b border-border">
|
||||
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 inline-flex items-center gap-1">
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
返回课程列表
|
||||
</Link>
|
||||
<h2 className="font-semibold text-foreground text-sm line-clamp-2 mt-2">{course.title}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
<BookOpen className="w-3 h-3 inline mr-1 align-text-bottom" />
|
||||
{course.chapters.length} 章 · {totalLessons} 课时
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{course.isFree ? (
|
||||
<span className="bg-green-100 text-green-700 px-2 py-0.5 rounded">免费</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="bg-orange-100 text-orange-700 px-2 py-0.5 rounded">¥{course.price}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setPayLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/payment/wxpay/unified-order`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
description: course.title,
|
||||
outTradeNo: `COURSE_${course.id}_${Date.now()}`,
|
||||
amount: course.price,
|
||||
tradeType: 'NATIVE',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.codeUrl) {
|
||||
setQrCodeUrl(data.codeUrl);
|
||||
setOrderNo(data.outTradeNo || '');
|
||||
setPaying(true);
|
||||
}
|
||||
} catch {}
|
||||
setPayLoading(false);
|
||||
}}
|
||||
disabled={payLoading}
|
||||
className="px-3 py-1 bg-brand-600 text-white text-xs rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{payLoading ? '处理中...' : '立即购买'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{course.category && (
|
||||
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeLesson ? (
|
||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
{activeLesson.content ? (
|
||||
<div className="text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{activeLesson.content}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<nav className="p-2">
|
||||
{course.chapters.map((chapter, ci) => (
|
||||
<div key={chapter.id} className="mb-3">
|
||||
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
|
||||
{ci + 1}. {chapter.title}
|
||||
</div>
|
||||
<p className="text-muted-foreground">请从左侧选择课时开始学习</p>
|
||||
{chapter.lessons.map((lesson, li) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => setActiveLesson(lesson)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeLesson?.id === lesson.id
|
||||
? 'bg-brand-50 text-brand-700 dark:bg-brand-950/30 dark:text-brand-400 font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground mr-2">{ci + 1}.{li + 1}</span>
|
||||
{lesson.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{course.chapters.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">暂无课时内容</p>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
|
||||
aria-label="切换侧边栏"
|
||||
>
|
||||
<Menu className="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-medium text-foreground truncate">
|
||||
{activeLesson?.title || course.title}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||
{course.isFree ? (
|
||||
<span className="bg-green-100 dark:bg-green-950/30 text-green-700 dark:text-green-400 px-2 py-0.5 rounded font-medium">
|
||||
免费
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="bg-brand-100 dark:bg-brand-950/30 text-brand-700 dark:text-brand-400 px-2 py-0.5 rounded font-medium">
|
||||
¥{course.price}
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/payment/wxpay/unified-order`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
description: course.title,
|
||||
outTradeNo: `COURSE_${course.id}_${Date.now()}`,
|
||||
amount: course.price,
|
||||
tradeType: 'NATIVE',
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.codeUrl) {
|
||||
setQrCodeUrl(data.codeUrl);
|
||||
}
|
||||
} catch {}
|
||||
}}
|
||||
className="px-3 py-1 bg-brand-600 text-white text-xs rounded-lg hover:bg-brand-700 transition-colors font-medium"
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{course.category && (
|
||||
<span className="bg-muted px-2 py-0.5 rounded">{course.category.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeLesson ? (
|
||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
|
||||
<div className="text-card-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{activeLesson.content ? (
|
||||
<div className="space-y-4 [&>h2]:text-lg [&>h2]:font-semibold [&>h2]:text-foreground [&>h2]:mt-8 [&>h2]:mb-3
|
||||
[&>h3]:text-base [&>h3]:font-semibold [&>h3]:text-foreground [&>h3]:mt-6 [&>h3]:mb-2
|
||||
[&>p]:text-muted-foreground [&>p]:leading-relaxed
|
||||
[&>ul]:list-disc [&>ul]:pl-6 [&>ul]:space-y-1 [&>ul]:text-muted-foreground
|
||||
[&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:space-y-1 [&>ol]:text-muted-foreground
|
||||
[&>li]:text-muted-foreground
|
||||
[&>strong]:text-foreground [&>strong]:font-semibold
|
||||
[&>code]:bg-muted [&>code]:px-1.5 [&>code]:py-0.5 [&>code]:rounded [&>code]:text-sm [&>code]:font-mono
|
||||
[&>pre]:bg-muted [&>pre]:p-4 [&>pre]:rounded-xl [&>pre]:overflow-x-auto [&>pre]:text-sm
|
||||
">
|
||||
{activeLesson.content}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground italic">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<BookOpen className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">请从左侧选择课时开始学习</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 课程 - AI 学习教程',
|
||||
description: '宇之然 AI 课程提供全面的 AI 学习教程,包括 AI 通识、提示词工程、智能体开发、模型百科、AI 沙盒实战等课程,让每个人都能学会使用 AI。',
|
||||
keywords: ['AI课程', 'AI教程', 'AI学习', 'AI通识', '提示词工程', '智能体教程', 'AI模型百科', 'AI沙盒', 'AI实战', 'ChatGPT教程', 'Claude教程', 'Midjourney教程', 'AI绘画教程', 'AI写作教程'],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'AI 课程 - AI 学习教程',
|
||||
description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。',
|
||||
type: 'website',
|
||||
url: 'https://yuzhiran.com/courses',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'AI 课程',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'AI 课程 - AI 学习教程',
|
||||
description: '宇之然 AI 课程提供全面的 AI 学习教程,让每个人都能学会使用 AI。',
|
||||
images: ['/images/og-image.png'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function CoursesLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -63,3 +63,130 @@
|
||||
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article body styles for markdown content */
|
||||
.article-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-body h1,
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.article-body h1 {
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.article-body h2 {
|
||||
font-size: 1.25rem;
|
||||
padding-left: 0.5em;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.article-body h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.article-body p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.article-body strong {
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.article-body a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-bottom-color 0.2s;
|
||||
}
|
||||
|
||||
.article-body a:hover {
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.article-body blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.75em 1em;
|
||||
border-left: 3px solid var(--primary);
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
border-radius: 0 0.5em 0.5em 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.article-body blockquote p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-body ul,
|
||||
.article-body ol {
|
||||
margin-bottom: 1em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.article-body li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.article-body hr {
|
||||
margin: 2em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.article-body code {
|
||||
font-family: 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||
font-size: 0.875em;
|
||||
padding: 0.125em 0.375em;
|
||||
background: var(--muted);
|
||||
border-radius: 0.25em;
|
||||
}
|
||||
|
||||
.article-body pre {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
background: var(--muted);
|
||||
border-radius: 0.5em;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.article-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.article-body img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.article-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.article-body th,
|
||||
.article-body td {
|
||||
padding: 0.5em 0.75em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.article-body th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+102
-8
@@ -9,26 +9,120 @@ export const metadata: Metadata = {
|
||||
default: '宇之然 AI - AI 工具与知识社区',
|
||||
template: '%s | 宇之然 AI',
|
||||
},
|
||||
description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科等,让每个人都能用好 AI。',
|
||||
keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然'],
|
||||
icons: {
|
||||
icon: '/favicon.png',
|
||||
shortcut: '/favicon.png',
|
||||
apple: '/icon.svg',
|
||||
description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等,让每个人都能用好 AI。',
|
||||
keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然', 'AI工具', 'AI教程', 'AI沙盒', '提示词工程', 'AI社区', '知识库', '编程学习', 'AI工具返利', 'AI工具联盟', 'AI工具推荐', 'AI工具测评'],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-video-preview': -1,
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
authors: [{ name: '宇之然科技', url: 'https://yuzhiran.com' }],
|
||||
creator: '宇之然科技',
|
||||
publisher: '宇之然科技',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL(SITE_URL),
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
languages: {
|
||||
'zh-CN': '/',
|
||||
'en': '/en',
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'zh_CN',
|
||||
siteName: '宇之然 AI',
|
||||
title: '宇之然 AI - AI 工具与知识社区',
|
||||
description: '让每个人都能用好 AI',
|
||||
description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战',
|
||||
url: SITE_URL,
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: '宇之然 AI - AI 工具与知识社区',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: '宇之然 AI - AI 工具与知识社区',
|
||||
description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战',
|
||||
images: ['/images/og-image.png'],
|
||||
},
|
||||
verification: {
|
||||
google: 'your-google-verification-code',
|
||||
yandex: 'your-yandex-verification-code',
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
{ url: '/favicon.png', type: 'image/png' },
|
||||
],
|
||||
shortcut: '/favicon.png',
|
||||
apple: [
|
||||
{ url: '/apple-icon.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
other: [
|
||||
{
|
||||
rel: 'apple-touch-icon-precomposed',
|
||||
url: '/apple-icon-precomposed.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: '宇之然 AI',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: '宇之然 AI',
|
||||
url: SITE_URL,
|
||||
description: '面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${SITE_URL}/search?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: '北京宇之然科技中心',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/favicon.png`,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="theme-color" content="#0c8ee7" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0a0a0a" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<RootLayoutClient>{children}</RootLayoutClient>
|
||||
<Script id="baidu-analytics" strategy="afterInteractive">
|
||||
@@ -43,4 +137,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag } from 'lucide-react';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const icons = [MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag];
|
||||
|
||||
export default function ModelsPage() {
|
||||
const t = useT();
|
||||
|
||||
const capabilities = [
|
||||
{ key: 'capChat', link: '/sandbox', cta: t.models.goSandbox },
|
||||
{ key: 'capCode', link: '/sandbox', cta: t.models.goSandbox },
|
||||
{ key: 'capData', link: '/sandbox', cta: t.models.goSandbox },
|
||||
{ key: 'capPrompt', link: '/prompts', cta: t.models.goPrompts },
|
||||
{ key: 'capPractice', link: '/practices', cta: t.models.goPractice },
|
||||
{ key: 'capMarketplace', link: '/marketplace', cta: t.models.goMarketplace },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.models.title}</h1>
|
||||
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.models.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{capabilities.map((cap, i) => {
|
||||
const Icon = icons[i];
|
||||
const title = t.models[cap.key as keyof typeof t.models] as string;
|
||||
const desc = t.models[`${cap.key}Desc` as keyof typeof t.models] as string;
|
||||
return (
|
||||
<Link
|
||||
key={cap.key}
|
||||
href={cap.link}
|
||||
className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
|
||||
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{desc}</p>
|
||||
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
|
||||
{cap.cta} →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+165
-53
@@ -3,7 +3,8 @@
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { HomePageClient } from './home-client';
|
||||
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap, Wrench } from 'lucide-react';
|
||||
import { ArrowRight, Sparkles, BookOpen, Bot, Wrench, GraduationCap, FileText, Puzzle, MessageSquare, Code, BarChart3 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useT } from '@/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
@@ -12,38 +13,42 @@ export default function HomePage() {
|
||||
const t = useT();
|
||||
const [courses, setCourses] = useState<any[]>([]);
|
||||
const [featuredTools, setFeaturedTools] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<{ courses: number; prompts: number; tools: number; users: number } | null>(null);
|
||||
const [stats, setStats] = useState<{ tools: number; courses: number; prompts: number; practices: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6&categoryId=7`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/courses?pageSize=3`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/public/stats`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6`).then(r => r.json()),
|
||||
]).then(([courseData, statsData, toolsData]) => {
|
||||
setCourses(courseData.items || []);
|
||||
fetch(`${API_BASE}/tools?pageSize=1`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/prompts?pageSize=1`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/practices?pageSize=1`).then(r => r.json()),
|
||||
]).then(([toolsData, coursesData, toolsAll, promptsData, practicesData]) => {
|
||||
setFeaturedTools(toolsData.items || []);
|
||||
setStats(statsData);
|
||||
}).catch(() => {});
|
||||
setCourses(coursesData.items || []);
|
||||
setStats({
|
||||
tools: toolsAll.total || toolsAll.items?.length || 0,
|
||||
courses: coursesData.total || coursesData.items?.length || 0,
|
||||
prompts: promptsData.total || promptsData.items?.length || 0,
|
||||
practices: practicesData.total || practicesData.items?.length || 0,
|
||||
});
|
||||
}).catch(() => {
|
||||
setStats({ tools: 0, courses: 0, prompts: 0, practices: 0 });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const statItems = [
|
||||
{ value: stats ? `${stats.courses}+` : '50+', label: t.home.statTopics },
|
||||
{ value: stats ? `${stats.prompts}+` : '200+', label: t.home.statPrompts },
|
||||
{ value: stats ? `${stats.tools}+` : '30+', label: t.home.statTools },
|
||||
{ value: stats ? `${stats.users / 1000 > 1 ? Math.round(stats.users / 100) * 100 + '+' : stats.users + '+'}` : '10,000+', label: t.home.statExplorers },
|
||||
];
|
||||
const features = [
|
||||
{ icon: Compass, title: t.home.featureGuide, desc: t.home.featureGuideDesc },
|
||||
{ icon: Bot, title: t.home.featureSandbox, desc: t.home.featureSandboxDesc },
|
||||
{ icon: BookOpen, title: t.home.featurePrompts, desc: t.home.featurePromptsDesc },
|
||||
{ icon: Zap, title: t.home.featureUpdate, desc: t.home.featureUpdateDesc },
|
||||
{ value: stats ? `${stats.tools}+` : '--', label: '收录工具', href: '/tools', icon: Wrench },
|
||||
{ value: stats ? `${stats.courses}+` : '--', label: '课程资源', href: '/courses', icon: BookOpen },
|
||||
{ value: stats ? `${stats.prompts}+` : '--', label: '提示词库', href: '/prompts', icon: FileText },
|
||||
{ value: stats ? `${stats.practices}+` : '--', label: '练习场景', href: '/practices', icon: Puzzle },
|
||||
];
|
||||
|
||||
return (
|
||||
<HomePageClient>
|
||||
{/* Hero */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-50 via-white to-blue-50 dark:from-brand-950/30 dark:via-background dark:to-blue-950/20" />
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiM2NjdlZWEiIGZpbGwtb3BhY2l0eT0iMC4wNCI+PHBhdGggZD0iTTM2IDM0djItSDI0di0yaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-50 dark:opacity-20" />
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiM2NjdlZWEiIGZpbGwtb3BhY2l0eT0iMC4wNCI+PHBhdGggZD0iTTM2IDM0djItSDI0di0xaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-50 dark:opacity-20" />
|
||||
<div className="absolute top-20 right-0 w-96 h-96 bg-brand-400/10 dark:bg-brand-400/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 left-20 w-72 h-72 bg-blue-400/10 dark:bg-blue-400/5 rounded-full blur-3xl" />
|
||||
|
||||
@@ -51,25 +56,25 @@ export default function HomePage() {
|
||||
<div className="text-center max-w-3xl mx-auto animate-fade-in-up">
|
||||
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 rounded-full mb-8 border border-brand-200 dark:border-brand-800">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
{t.home.badge}
|
||||
精选AI工具 · 真实评测
|
||||
</span>
|
||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight">
|
||||
<span className="bg-gradient-to-r from-brand-600 via-brand-500 to-blue-500 bg-clip-text text-transparent">
|
||||
{t.home.heroHighlight}
|
||||
找AI工具?先看真实评测
|
||||
</span>
|
||||
<br />{t.home.heroRest}
|
||||
</h1>
|
||||
<p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
{t.home.desc}
|
||||
汇集优质AI工具,提供真实使用评测、详细参数对比和专业教程。帮你快速找到合适工具,系统化提升AI应用能力。
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/courses" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
{t.home.startExplore}
|
||||
<Link href="/tools" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]">
|
||||
<Wrench className="w-5 h-5" />
|
||||
浏览工具库
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link href="/auth?tab=register" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm">
|
||||
{t.home.freeRegister}
|
||||
<Link href="/sandbox" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm">
|
||||
<Bot className="w-5 h-5" />
|
||||
沙盒体验
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,12 +86,16 @@ export default function HomePage() {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{statItems.map((s) => (
|
||||
<div key={s.label} className="text-center group">
|
||||
<Link key={s.label} href={s.href} className="text-center group">
|
||||
<div className="text-3xl md:text-4xl font-bold bg-gradient-to-b from-brand-600 to-brand-400 bg-clip-text text-transparent group-hover:scale-110 transition-transform">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="mt-1.5 text-sm text-muted-foreground">{s.label}</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-center gap-1.5 text-sm text-muted-foreground group-hover:text-brand-600 transition-colors">
|
||||
<s.icon className="w-3.5 h-3.5" />
|
||||
{s.label}
|
||||
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,48 +105,151 @@ export default function HomePage() {
|
||||
{featuredTools.length > 0 && (
|
||||
<section className="py-16 bg-muted/30">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-3xl font-bold">{t.home.featuredToolsTitle}</h2>
|
||||
<p className="mt-2 text-muted-foreground">{t.home.featuredToolsDesc}</p>
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">精选AI工具推荐</h2>
|
||||
<p className="mt-2 text-muted-foreground">每个工具都经过实际测试,附带详细评测,助你快速决策</p>
|
||||
</div>
|
||||
<Link href="/tools" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
|
||||
查看全部 <ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{featuredTools.map((tool: any) => (
|
||||
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
|
||||
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
|
||||
{featuredTools.map((tool: any) => {
|
||||
const highlightMatch = tool.description?.match(/【推荐理由】\s*(.+?)(?:\n|$)/);
|
||||
const baseDesc = tool.description?.split('\n\n')[0] || '';
|
||||
return (
|
||||
<Link key={tool.id} href={`/tools/${tool.slug}`} className="block group">
|
||||
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-brand-100 dark:bg-brand-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{tool.description}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{baseDesc}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</div>
|
||||
{highlightMatch && (
|
||||
<div className="mt-2 px-2 py-1 bg-brand-50 dark:bg-brand-950/40 rounded-md">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 font-medium">💡 {highlightMatch[1].trim().slice(0, 35)}</p>
|
||||
</div>
|
||||
)}
|
||||
{tool.tags && (
|
||||
<div className="flex gap-1.5 mt-2 flex-wrap">
|
||||
{tool.tags.split(',').slice(0, 3).map((tag: string) => (
|
||||
<span key={tag} className="text-xs px-2 py-0.5 bg-muted rounded-full text-muted-foreground">{tag.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{/* Marketplace Skills Section */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold">💼 AI 技能广场</h2>
|
||||
<p className="mt-2 text-muted-foreground">即用即走的 AI 工作流技能,解决具体业务问题,¥9.9 起</p>
|
||||
</div>
|
||||
<Link href="/marketplace" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
|
||||
查看全部技能 <ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Link href="/marketplace" className="group">
|
||||
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xl">⚡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">标题大师</h3>
|
||||
<p className="text-xs text-muted-foreground">AI 生成高点击标题</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400">¥9.9</Badge>
|
||||
<span className="text-xs text-muted-foreground">已售1000+</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/marketplace" className="group">
|
||||
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xl">📋</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">提案智造</h3>
|
||||
<p className="text-xs text-muted-foreground">5个问题生成专业商业提案</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-400">¥19.9</Badge>
|
||||
<span className="text-xs text-muted-foreground">6个技能可选</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link href="/marketplace" className="group">
|
||||
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all h-full cursor-pointer">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center shrink-0">
|
||||
<span className="text-xl">⚖️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">合同审查助手</h3>
|
||||
<p className="text-xs text-muted-foreground">AI 标记风险条款并给出修改建议</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="bg-purple-50 dark:bg-purple-950/40 text-purple-700 dark:text-purple-400">¥29.9</Badge>
|
||||
<span className="text-xs text-muted-foreground">专业级</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* AI 能力导览 */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold">{t.home.whyTitle}</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">{t.home.whyDesc}</p>
|
||||
<h2 className="text-3xl font-bold">AI 能力导览</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">探索 AI 能为你做什么,从对话写作到编程实战,找到适合你的场景</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{features.map((feat) => (
|
||||
<div key={feat.title} className="group text-center p-8 rounded-2xl bg-card border border-border hover:border-brand-200 dark:hover:border-brand-800 transition-all hover:-translate-y-1 hover:shadow-lg">
|
||||
<div className="w-14 h-14 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center mx-auto mb-5 group-hover:scale-110 transition-transform">
|
||||
<feat.icon className="w-7 h-7 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{feat.title}</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{feat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ icon: MessageSquare, title: '对话写作', desc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', href: '/sandbox', cta: '打开沙盒' },
|
||||
{ icon: Code, title: '编程辅助', desc: '生成代码、调试错误、解释算法,你的随身编程搭档', href: '/sandbox', cta: '打开沙盒' },
|
||||
{ icon: BarChart3, title: '数据分析', desc: '分析数据、生成图表、洞察趋势,让数据说话', href: '/sandbox', cta: '打开沙盒' },
|
||||
{ icon: FileText, title: '提示词工程', desc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', href: '/prompts', cta: '浏览提示词库' },
|
||||
{ icon: GraduationCap, title: '技能实战', desc: '在真实场景中练习 AI 技能,获得即时评分反馈', href: '/practices', cta: '开始练习' },
|
||||
{ icon: Puzzle, title: '工作流技能', desc: '购买即用的 AI 工作流技能,解决具体业务问题', href: '/marketplace', cta: '前往技能广场' },
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link key={item.title} href={item.href} className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all">
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
|
||||
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{item.desc}</p>
|
||||
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
|
||||
{item.cta} →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Cloud, ExternalLink, ArrowRight, Server, Database, Globe, Cpu } from 'lucide-react';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
|
||||
const categoryIcons: Record<string, typeof Cloud> = {
|
||||
'阿里云': Cloud,
|
||||
'云大使': Server,
|
||||
};
|
||||
|
||||
function ResourceSkeleton() {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<Skeleton className="h-5 w-3/4 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-3" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'云服务器': '云服务器',
|
||||
'存储': '存储',
|
||||
'AI': 'AI 服务',
|
||||
'建站': '建站',
|
||||
};
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const [resources, setResources] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/tools?categoryId=9`)
|
||||
.then(r => r.json()).then(data => setResources(data.items || []))
|
||||
.catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center">
|
||||
<Cloud className="w-6 h-6 text-brand-600 dark:text-brand-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">云资源推荐</h1>
|
||||
<p className="text-muted-foreground mt-1">精选云服务商优惠活动与资源推荐,助你低成本上云</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intro Banner */}
|
||||
<Card className="p-6 mb-10 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-xl flex items-center justify-center shrink-0">
|
||||
<Globe className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg mb-1">阿里云官方推荐</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
以下链接为阿里云官方推广合作,通过本站链接访问可享受专属优惠价格。
|
||||
云服务器、存储、AI 服务等热门产品均有活动,适合个人开发者与企业用户。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Resource List */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1,2,3,4,5,6].map(i => <ResourceSkeleton key={i} />)}
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<Cloud className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
||||
<p>暂无云资源推荐</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{resources.map((item: any) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={item.affiliateLink || item.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block group"
|
||||
>
|
||||
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group h-full flex flex-col">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-xl flex items-center justify-center shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Server className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold group-hover:text-brand-600 transition-colors line-clamp-1">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-auto">
|
||||
{item.tags?.split(',').slice(0, 2).map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">{tag.trim()}</Badge>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-brand-600 group-hover:underline inline-flex items-center gap-1">
|
||||
查看详情 <ExternalLink className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Note */}
|
||||
<div className="mt-12 text-center text-xs text-muted-foreground">
|
||||
<p>本站部分链接为阿里云推广合作链接,通过本链接购买可能会产生推广佣金,不影响您的购买价格。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,14 @@ interface Skill {
|
||||
starters: string[]; tasks: SkillTask[]; tags: string[];
|
||||
prerequisites?: string[];
|
||||
}
|
||||
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
|
||||
|
||||
export default function SkillDetailPage() {
|
||||
const params = useParams();
|
||||
const t = useT();
|
||||
const [skill, setSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.id) return;
|
||||
@@ -29,6 +31,24 @@ export default function SkillDetailPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!skill?.id) return;
|
||||
fetch(`${API_BASE}/affiliate/links?skillId=${skill.id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => setLinks(data.links || []))
|
||||
.catch(() => {});
|
||||
}, [skill?.id]);
|
||||
|
||||
async function handlePromote(link: AffiliateLink) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
|
||||
} catch {
|
||||
window.open(link.url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
@@ -108,6 +128,29 @@ export default function SkillDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="mt-6 bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t.affiliate.recommendForSkill}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{links.map(link => (
|
||||
<div key={link.id} className="flex items-center justify-between gap-3 p-3 border border-border rounded-xl">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">{link.title}</div>
|
||||
{link.description && (
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">{link.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePromote(link)}
|
||||
className="shrink-0 px-3 py-1.5 text-xs bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors">
|
||||
{t.affiliate.promoteLink}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Wrench, ExternalLink, Star, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface ToolDetail {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
metaTitle: string | null;
|
||||
metaDesc: string | null;
|
||||
description: string | null;
|
||||
content: string | null;
|
||||
url: string;
|
||||
icon: string | null;
|
||||
affiliateLink: string | null;
|
||||
tags: string | null;
|
||||
isFeatured: boolean;
|
||||
viewCount: number;
|
||||
category: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
async function getTool(slug: string): Promise<ToolDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'}/tools/${slug}`, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
|
||||
const tool = await getTool(params.slug);
|
||||
if (!tool) return { title: '工具未找到' };
|
||||
|
||||
return {
|
||||
title: tool.metaTitle || `${tool.name} - 功能介绍、使用教程、评测 | 宇之然 AI 工具指南`,
|
||||
description: tool.metaDesc || tool.description || `${tool.name} AI工具详细介绍、使用教程和评测`,
|
||||
openGraph: {
|
||||
title: tool.metaTitle || `${tool.name} - AI工具评测`,
|
||||
description: tool.metaDesc || tool.description || '',
|
||||
type: 'article',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
let apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
|
||||
// Build-time fetch requires absolute URL; if relative, prefix localhost:4000
|
||||
if (!apiBase.startsWith('http')) {
|
||||
apiBase = `http://localhost:4000${apiBase.startsWith('/') ? '' : '/'}${apiBase}`;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/tools?pageSize=100`);
|
||||
const data = await res.json();
|
||||
return (data.items || []).map((tool: any) => ({ slug: tool.slug }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ToolDetailPage({ params }: { params: { slug: string } }) {
|
||||
const tool = await getTool(params.slug);
|
||||
if (!tool) notFound();
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
url: tool.url,
|
||||
applicationCategory: 'AI工具',
|
||||
};
|
||||
|
||||
// 解析描述中的结构化字段
|
||||
function parseSection(desc: string | null, key: string): string | null {
|
||||
if (!desc) return null;
|
||||
const match = desc.match(new RegExp(`【${key}】\\s*(.+?)(?:\\n\\n|$)`, 's'));
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
function parseBaseDesc(desc: string | null): string {
|
||||
if (!desc) return '';
|
||||
return desc.split('\n\n')[0] || '';
|
||||
}
|
||||
const reason = parseSection(tool.description, '推荐理由');
|
||||
const audience = parseSection(tool.description, '适合人群');
|
||||
const baseDesc = parseBaseDesc(tool.description);
|
||||
|
||||
// 收益标签映射
|
||||
const roiTags: Record<string, string> = {
|
||||
'编程': '日省2-4小时编码时间',
|
||||
'图像': '替代部分设计师,省¥1000-5000/月',
|
||||
'视频': '短视频效率提升5-10倍',
|
||||
'对话': '替代部分人工客服,省¥3000-8000/月',
|
||||
'办公': '文档效率提升3-5倍',
|
||||
'搜索': '调研效率提升5-10倍',
|
||||
'语音': '语音处理效率提升10倍',
|
||||
};
|
||||
const category = tool.tags ? tool.tags.split(',')[0] : '';
|
||||
const roiTag = roiTags[category] || '提升工作效率';
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 面包屑导航 */}
|
||||
<nav className="flex items-center gap-2 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/" className="hover:text-foreground">首页</Link>
|
||||
<span>/</span>
|
||||
<Link href="/tools" className="hover:text-foreground">AI工具</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{tool.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* 工具头部 */}
|
||||
<div className="flex items-start gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-brand-100 dark:bg-brand-900/30 rounded-2xl flex items-center justify-center shrink-0">
|
||||
{tool.icon ? (
|
||||
<img src={tool.icon} alt={tool.name} className="w-10 h-10" />
|
||||
) : (
|
||||
<Wrench className="w-8 h-8 text-brand-600 dark:text-brand-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-3xl font-bold text-foreground">{tool.name}</h1>
|
||||
{tool.isFeatured && <Star className="w-5 h-5 text-amber-500 fill-amber-500" />}
|
||||
</div>
|
||||
{tool.category && (
|
||||
<Badge variant="secondary" className="mt-2">{tool.category.name}</Badge>
|
||||
)}
|
||||
{baseDesc && <p className="mt-3 text-lg text-muted-foreground">{baseDesc}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐理由 + 收益标签 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{reason && (
|
||||
<Card className="p-5 bg-gradient-to-br from-brand-50 to-amber-50/50 dark:from-brand-950/40 dark:to-amber-950/20 border-brand-200 dark:border-brand-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">💡</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-brand-700 dark:text-brand-300 text-sm mb-1">推荐理由</h3>
|
||||
<p className="text-sm text-foreground">{reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<Card className="p-5 bg-gradient-to-br from-emerald-50 to-teal-50/50 dark:from-emerald-950/40 dark:to-teal-950/20 border-emerald-200 dark:border-emerald-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📈</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-emerald-700 dark:text-emerald-300 text-sm mb-1">预期收益</h3>
|
||||
<p className="text-sm text-foreground">{roiTag}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 适用人群 */}
|
||||
{audience && (
|
||||
<Card className="p-5 mb-8 bg-gradient-to-br from-purple-50 to-pink-50/50 dark:from-purple-950/40 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-semibold text-purple-700 dark:text-purple-300 text-sm mb-1">适合人群</h3>
|
||||
<p className="text-sm text-foreground">{audience}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 mb-10">
|
||||
<a href={tool.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button className="gap-2">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
访问 {tool.name}
|
||||
</Button>
|
||||
</a>
|
||||
<Link href="/sandbox">
|
||||
<Button variant="outline" className="gap-2">
|
||||
在沙盒中体验
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 详细介绍 */}
|
||||
{tool.content && (
|
||||
<Card className="p-6 mb-8">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
{tool.content.split('\n').map((line, i) => {
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-bold mt-6 mb-3">{line.slice(3)}</h2>;
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-bold mt-4 mb-2">{line.slice(4)}</h3>;
|
||||
if (line.startsWith('- **')) {
|
||||
const match = line.match(/- \*\*(.+?)\*\*(:?)\s*(.*)/);
|
||||
if (match) return <p key={i}><strong>{match[1]}</strong>{match[3] && `: ${match[3]}`}</p>;
|
||||
}
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>;
|
||||
if (line.trim() === '') return <div key={i} className="h-2" />;
|
||||
return <p key={i} className="mb-2">{line}</p>;
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{tool.tags && (
|
||||
<div className="flex gap-2 flex-wrap mb-8">
|
||||
{tool.tags.split(',').map(tag => (
|
||||
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<Card className="p-6 bg-gradient-to-r from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">准备好使用 {tool.name} 了吗?</h3>
|
||||
</div>
|
||||
<a href={tool.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button>立即使用 →</Button>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 浏览量 */}
|
||||
<p className="text-xs text-muted-foreground mt-6 text-center">
|
||||
此页面已被浏览 {tool.viewCount} 次
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 工具指南 - 精选 AI 工具推荐',
|
||||
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,包括 AI 写作、AI 绘画、AI 编程、AI 办公、AI 设计等分类工具,每个工具都有详细测评和使用指南。',
|
||||
keywords: ['AI工具', 'AI工具推荐', 'AI工具测评', 'AI工具导航', 'AI写作工具', 'AI绘画工具', 'AI编程工具', 'AI办公工具', 'AI设计工具', 'AI工具返利', 'AI工具联盟', 'ChatGPT', 'Claude', 'Midjourney', 'Stable Diffusion', '豆包', '通义千问', '文心一言'],
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: 'AI 工具指南 - 精选 AI 工具推荐',
|
||||
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,每个工具都有详细测评和使用指南。',
|
||||
type: 'website',
|
||||
url: 'https://yuzhiran.com/tools',
|
||||
images: [
|
||||
{
|
||||
url: '/images/og-image.png',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'AI 工具指南',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'AI 工具指南 - 精选 AI 工具推荐',
|
||||
description: '宇之然 AI 工具指南收录了最新的 AI 工具推荐,每个工具都有详细测评和使用指南。',
|
||||
images: ['/images/og-image.png'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function ToolsLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -25,11 +25,17 @@ export default function ToolsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/tools`)
|
||||
fetch(`${API_BASE}/tools?categoryId=7`)
|
||||
.then(r => r.json()).then(data => setTools(data.items || []))
|
||||
.catch(() => {}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function parseHighlight(desc: string | null): string {
|
||||
if (!desc) return '';
|
||||
const match = desc.match(/【推荐理由】\s*(.+?)(?:\n|$)/);
|
||||
return match ? match[1].trim() : desc.slice(0, 40) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-10">
|
||||
@@ -41,7 +47,9 @@ export default function ToolsPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{[1,2,3,4,5,6].map(i => <ToolSkeleton key={i} />)}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{tools.map((tool) => (
|
||||
{tools.map((tool) => {
|
||||
const highlight = parseHighlight(tool.description);
|
||||
return (
|
||||
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
|
||||
<Card className="p-5 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
@@ -51,13 +59,17 @@ export default function ToolsPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{tool.name}</h3>
|
||||
{tool.affiliateLink && <Badge className="bg-green-600 hover:bg-green-700 text-white text-[10px] px-1.5 py-0">推荐</Badge>}
|
||||
{tool.isFeatured && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{tool.description}</p>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{tool.description?.split('\n\n')[0]}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-1" />
|
||||
</div>
|
||||
{highlight && (
|
||||
<div className="mt-2 px-3 py-1.5 bg-brand-50 dark:bg-brand-950/40 rounded-lg border border-brand-100 dark:border-brand-800">
|
||||
<p className="text-xs text-brand-600 dark:text-brand-400 font-medium">💡 {highlight}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{tool.tags?.split(',').slice(0, 2).map(tag => (
|
||||
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
|
||||
@@ -65,7 +77,8 @@ export default function ToolsPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function Footer() {
|
||||
<li><Link href="/courses" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.nav.courses}</Link></li>
|
||||
<li><Link href="/sandbox" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.nav.sandbox}</Link></li>
|
||||
<li><Link href="/prompts" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.nav.prompts}</Link></li>
|
||||
<li><Link href="/models" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.footer.models}</Link></li>
|
||||
<li><Link href="/resources" className="text-sm text-muted-foreground hover:text-foreground transition-colors">云资源</Link></li>
|
||||
<li><Link href="/tools" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.footer.aiTools}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -53,35 +53,18 @@ export function Header() {
|
||||
const { lang, setLang } = useLang();
|
||||
const t = useT();
|
||||
|
||||
const mainNavItems = [
|
||||
const navItems = [
|
||||
{ href: '/', label: t.nav.home },
|
||||
{ href: '/marketplace', label: t.nav.marketplace },
|
||||
{ href: '/tools', label: t.nav.tools },
|
||||
{ href: '/courses', label: t.nav.courses },
|
||||
{ href: '/sandbox', label: t.nav.sandbox },
|
||||
{ href: '/practices', label: t.nav.practices },
|
||||
// { href: '/community', label: t.nav.community },
|
||||
// { href: '/enterprise', label: t.nav.enterprise },
|
||||
];
|
||||
|
||||
const secondaryNavItems = [
|
||||
{ href: '/courses', label: t.nav.courses },
|
||||
{ href: '/prompts', label: t.nav.prompts },
|
||||
{ href: '/models', label: t.nav.models },
|
||||
{ href: '/tools', label: t.nav.tools },
|
||||
{ href: '/resources', label: '云资源' },
|
||||
{ href: '/marketplace', label: t.nav.marketplace },
|
||||
{ href: '/contents', label: t.nav.articles },
|
||||
];
|
||||
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function openMore() {
|
||||
if (moreTimer.current) clearTimeout(moreTimer.current);
|
||||
setMoreOpen(true);
|
||||
}
|
||||
|
||||
function closeMore() {
|
||||
moreTimer.current = setTimeout(() => setMoreOpen(false), 200);
|
||||
}
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
@@ -108,12 +91,12 @@ export function Header() {
|
||||
<span className="text-lg text-muted-foreground hidden sm:inline">{t.brand.suffix}</span>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{mainNavItems.map((item) => (
|
||||
<nav className="hidden md:flex items-center gap-1 flex-wrap">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
className={`px-2.5 py-1.5 text-sm font-medium rounded-lg transition-all ${
|
||||
isActive(item.href)
|
||||
? 'bg-accent text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
@@ -122,46 +105,6 @@ export function Header() {
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={openMore}
|
||||
onMouseLeave={closeMore}
|
||||
>
|
||||
<button
|
||||
className={`flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
moreOpen
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{t.nav.more}
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${moreOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 pt-1"
|
||||
onMouseEnter={openMore}
|
||||
onMouseLeave={closeMore}
|
||||
>
|
||||
{moreOpen && (
|
||||
<div className="w-40 bg-popover border border-border rounded-lg shadow-lg py-1 animate-fade-in">
|
||||
{secondaryNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block px-3 py-2 text-sm transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-accent text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setMoreOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
@@ -171,7 +114,7 @@ export function Header() {
|
||||
name="q"
|
||||
type="text"
|
||||
placeholder={t.common.search}
|
||||
className="w-36 lg:w-48 pl-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
className="w-28 lg:w-36 pl-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||
/>
|
||||
</form>
|
||||
<ThemeToggle />
|
||||
@@ -227,22 +170,7 @@ export function Header() {
|
||||
|
||||
{mobileOpen && (
|
||||
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
|
||||
{mainNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block py-2.5 px-2 text-sm rounded-lg transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-accent text-foreground font-semibold'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-border my-2 mx-2" />
|
||||
{secondaryNavItems.map((item) => (
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
|
||||
@@ -15,6 +15,7 @@ const en: Translations = {
|
||||
courses: { title: 'Courses', desc: 'Explore AI systematically from beginner to expert', empty: 'No courses available', free: 'Free', paid: 'Paid', moduleCount: '{n} modules' },
|
||||
search: { title: 'Search Results', placeholder: 'Search courses, prompts, tools, articles...', emptyHint: 'Enter keywords to search', noResults: 'No results found for "{q}"', resultsCount: '{n} results found', groupCourse: 'Courses', groupPrompt: 'Prompts', groupTool: 'AI Tools', groupContent: 'Articles' },
|
||||
tools: { title: 'AI Tools', desc: 'Curated AI tools to boost your productivity' },
|
||||
affiliate: { title: 'AI Tools Affiliate', desc: 'Recommend quality AI tools and earn compliant platform commissions', totalClicks: 'Total Clicks', totalRevenue: 'Est. Revenue', partnerTools: 'Partner Tools', recommendTools: 'Recommended Tools', ranking: 'Revenue Ranking', tool: 'Tool', clicks: 'Clicks', revenue: 'Revenue', visitTool: 'Visit Tool', promoteLink: 'Affiliate Link', noData: 'No affiliate links yet', disclaimer: 'Affiliate revenue is settled by third-party platforms. Yuzhiran only provides referral entry and does not charge operating fees', recommendForSkill: 'Recommended tools for this skill' },
|
||||
discover: { desc: 'Explore trending content and curated picks', hotCourses: 'Hot Courses', hotPrompts: 'Trending Prompts', hotPosts: 'Popular Discussions', viewCount: '{n} views', likeCount: '{n} likes', postStats: '❤️ {likes} · 👁 {views}', toolsTitle: 'AI Tool Picks', toolsDesc: 'Curated popular AI tools' },
|
||||
circles: { title: 'Circles', desc: 'Topic-based discussion groups', empty: 'No circles yet', back: 'Back to Discover', members: 'members', posts: 'posts' },
|
||||
brand: { name: 'Yuzhiran', suffix: 'AI' },
|
||||
|
||||
@@ -13,6 +13,7 @@ const zh = {
|
||||
courses: { title: '专题', desc: '系统化探索 AI,从入门到精通', empty: '暂无专题内容', free: '免费', paid: '付费', moduleCount: '{n} 模块' },
|
||||
search: { title: '搜索结果', placeholder: '搜索专题、提示词、工具、文章...', emptyHint: '输入关键词搜索', noResults: '未找到与 "{q}" 相关的结果', resultsCount: '找到 {n} 个结果', groupCourse: '专题', groupPrompt: '提示词', groupTool: 'AI 工具', groupContent: '文章' },
|
||||
tools: { title: 'AI 工具库', desc: '收录优质 AI 工具,助力工作效率提升' },
|
||||
affiliate: { title: 'AI 工具返利联盟', desc: '推荐优质 AI 工具,合规获取平台返佣', totalClicks: '总点击量', totalRevenue: '预估收益', partnerTools: '合作工具', recommendTools: '可推荐工具', ranking: '收益排行榜', tool: '工具', clicks: '点击量', revenue: '预估收益', visitTool: '访问工具', promoteLink: '推广链接', noData: '暂无推广链接,敬请期待', disclaimer: '联盟收益由第三方平台结算,宇之然仅提供推荐入口,不构成经营性收费', recommendForSkill: '学此技能推荐使用的工具' },
|
||||
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览', toolsTitle: '工具推荐', toolsDesc: '精选热门AI工具' },
|
||||
circles: { title: '圈子', desc: '按领域划分的垂直讨论区', empty: '暂无圈子', back: '返回发现', members: '人', posts: '帖' },
|
||||
brand: { name: '宇之然', suffix: 'AI' },
|
||||
|
||||
Reference in New Issue
Block a user