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:
TradeMate Dev
2026-07-11 14:47:41 +08:00
parent 05bf267d09
commit 31cc1e02ce
46 changed files with 3565 additions and 1201 deletions
+13
View File
@@ -30,6 +30,7 @@
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"lucide-react": "^1.14.0",
"marked": "^18.0.5",
"next": "^14.2.35",
"next-themes": "^0.4.6",
"postcss": "^8.5.14",
@@ -8044,6 +8045,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.5",
"resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz",
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -48,6 +48,7 @@
"framer-motion": "^12.38.0",
"highlight.js": "^11.11.1",
"lucide-react": "^1.14.0",
"marked": "^18.0.5",
"next": "^14.2.35",
"next-themes": "^0.4.6",
"postcss": "^8.5.14",
+88
View File
@@ -1,4 +1,92 @@
# 宇之然 AI - robots.txt
# https://yuzhiran.com/robots.txt
User-agent: *
Allow: /
# 禁止抓取后台管理页面
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
# 禁止抓取 API 路径
Disallow: /api/
Disallow: /_next/
# 允许搜索引擎抓取重要页面
Allow: /tools/
Allow: /courses/
Allow: /skills/
Allow: /practices/
Allow: /prompts/
Allow: /contents/
Allow: /community/
Allow: /discover/
Allow: /sandbox/
Allow: /marketplace/
Allow: /about/
Allow: /privacy/
Allow: /terms/
# 爬虫抓取频率限制
Crawl-delay: 1
# 搜索引擎验证
User-agent: Googlebot
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 0
User-agent: Bingbot
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 1
User-agent: Baiduspider
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 2
User-agent: Yandex
Allow: /
Disallow: /admin/
Disallow: /auth/
Disallow: /my/
Disallow: /dashboard/
Disallow: /notifications/
Disallow: /enterprise/
Disallow: /learning/
Disallow: /api/
Disallow: /_next/
Crawl-delay: 1
# Sitemap 位置
Sitemap: https://yuzhiran.com/sitemap.xml
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "https://json.schemastore.org/webmanifest",
"name": "宇之然 AI - AI 工具与知识社区",
"short_name": "宇之然 AI",
"description": "面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等",
"start_url": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#ffffff",
"theme_color": "#0c8ee7",
"categories": ["education", "productivity", "utilities"],
"lang": "zh-CN",
"dir": "ltr",
"scope": "/",
"icons": [
{
"src": "/favicon.ico",
"sizes": "any",
"type": "image/x-icon"
},
{
"src": "/favicon.png",
"sizes": "16x16 32x32 48x48 64x64 128x128 256x256",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "AI 工具",
"short_name": "工具",
"description": "浏览 AI 工具指南",
"url": "/tools",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "AI 沙盒",
"short_name": "沙盒",
"description": "AI 对话沙盒实战",
"url": "/sandbox",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "课程学习",
"short_name": "课程",
"description": "AI 课程学习",
"url": "/courses",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
},
{
"name": "技能广场",
"short_name": "技能",
"description": "AI 技能广场",
"url": "/skills",
"icons": [{ "src": "/favicon.png", "sizes": "192x192" }]
}
],
"share_target": {
"action": "/search",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
+13
View File
@@ -13,11 +13,24 @@ const PRIORITIES = {
'/practices': 0.8,
'/contents': 0.8,
'/prompts': 0.8,
'/prompts/workshop': 0.7,
'/models': 0.7,
'/sandbox': 0.7,
'/sandbox/compare': 0.6,
'/sandbox/code': 0.6,
'/sandbox/share': 0.6,
'/discover': 0.7,
'/discover/hot': 0.6,
'/discover/tools': 0.6,
'/discover/daily': 0.6,
'/community': 0.6,
'/circles': 0.6,
'/resources': 0.7,
'/about': 0.5,
'/privacy': 0.3,
'/terms': 0.3,
'/ai-agreement': 0.3,
'/search': 0.5,
};
function walkDir(dir, basePath = '') {
+1 -1
View File
@@ -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() {
+39
View File
@@ -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;
}
+218
View File
@@ -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>
);
}
+224 -65
View File
@@ -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">
&larr;
</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>
);
}
}
+143 -5
View File
@@ -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">
&larr;
</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>
);
}
}
+200 -168
View File
@@ -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">
&larr;
</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>
</>
);
}
}
+39
View File
@@ -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;
}
+127
View File
@@ -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
View File
@@ -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>
);
}
}
-53
View File
@@ -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
View File
@@ -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>
+124
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+241
View File
@@ -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>
</>
);
}
+39
View File
@@ -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;
}
+18 -5
View File
@@ -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>
+1 -1
View File
@@ -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>
+10 -82
View File
@@ -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}
+1
View File
@@ -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' },
+1
View File
@@ -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' },
+283
View File
@@ -0,0 +1,283 @@
#!/usr/bin/env python3
"""
ai-learning-platform (yuzhiran.com) 浏览器自动化测试
测试 SEO 优化、页面加载、功能完整性
"""
import subprocess
import json
import re
import sys
import time
from urllib.request import urlopen, Request
from urllib.error import HTTPError, URLError
from html.parser import HTMLParser
BASE_URL = "https://yuzhiran.com"
results = []
def run_test(name, test_func):
"""运行单个测试"""
try:
test_func()
results.append({"name": name, "status": "PASS", "error": None})
print(f"{name}")
except AssertionError as e:
results.append({"name": name, "status": "FAIL", "error": str(e)})
print(f"{name}: {e}")
except Exception as e:
results.append({"name": name, "status": "ERROR", "error": str(e)})
print(f" ⚠️ {name}: {e}")
def fetch_page(path, timeout=15):
"""获取页面内容"""
url = f"{BASE_URL}{path}"
req = Request(url, headers={
"User-Agent": "Mozilla/5.0 (compatible; Hermes-Test/1.0)",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
})
resp = urlopen(req, timeout=timeout)
return resp.read().decode('utf-8', errors='replace'), resp
def check_meta_tags(html, path="/"):
"""检查 meta 标签"""
# Title
title_match = re.search(r'<title[^>]*>(.*?)</title>', html, re.DOTALL | re.IGNORECASE)
assert title_match, f"Missing <title> tag on {path}"
title = title_match.group(1).strip()
assert len(title) > 5, f"Title too short: '{title}' on {path}"
# Description
desc_match = re.search(r'<meta[^>]+name=["\']description["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert desc_match, f"Missing description meta tag on {path}"
desc = desc_match.group(1).strip()
assert len(desc) > 20, f"Description too short: '{desc[:50]}' on {path}"
# Keywords
kw_match = re.search(r'<meta[^>]+name=["\']keywords["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert kw_match, f"Missing keywords meta tag on {path}"
# Viewport
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert vp_match, f"Missing viewport meta tag on {path}"
vp = vp_match.group(1).strip()
assert "width=device-width" in vp.lower(), f"Viewport missing width=device-width on {path}"
# Canonical
canon_match = re.search(r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert canon_match, f"Missing canonical link tag on {path}"
# Open Graph
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert og_match, f"Missing og:title meta tag on {path}"
# Theme color
tc_match = re.search(r'<meta[^>]+name=["\']theme-color["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert tc_match, f"Missing theme-color meta tag on {path}"
def check_structured_data(html, path="/"):
"""检查结构化数据 (JSON-LD)"""
ld_match = re.search(r'<script[^>]+type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.DOTALL | re.IGNORECASE)
assert ld_match, f"Missing JSON-LD structured data on {path}"
ld_content = ld_match.group(1).strip()
try:
data = json.loads(ld_content)
assert '@context' in data, f"JSON-LD missing @context on {path}"
assert '@type' in data, f"JSON-LD missing @type on {path}"
except json.JSONDecodeError:
assert False, f"Invalid JSON-LD on {path}"
def check_performance(html, path="/"):
"""检查性能相关优化"""
# Check for inline scripts (should be minimal)
inline_scripts = re.findall(r'<script[^>]*>(?!.*?</script>)([^<]{100,})', html, re.DOTALL | re.IGNORECASE)
# Large inline scripts are bad for performance
large_scripts = [s for s in inline_scripts if len(s) > 5000]
assert len(large_scripts) <= 2, f"Too many large inline scripts ({len(large_scripts)}) on {path}"
# Check for preconnect hints
preconnect = re.findall(r'<link[^>]+rel=["\']preconnect["\']', html, re.IGNORECASE)
# Not required but good to have
print(f" Preconnect hints: {len(preconnect)}")
def check_semantic_html(html, path="/"):
"""检查语义化 HTML"""
# Should have proper heading structure
h1_count = len(re.findall(r'<h1[^>]*>', html, re.IGNORECASE))
assert h1_count >= 1, f"No <h1> tag on {path}"
# Should have main element or equivalent
main_match = re.search(r'<main[^>]*>', html, re.IGNORECASE)
nav_match = re.search(r'<nav[^>]*>', html, re.IGNORECASE)
assert main_match or nav_match, f"No <main> or <nav> element on {path}"
def check_image_optimization(html, path="/"):
"""检查图片优化"""
# Check for alt attributes on images
images = re.findall(r'<img[^>]*>', html, re.IGNORECASE)
for img in images[:10]: # Check first 10 images
alt_match = re.search(r'alt=["\'](.*?)["\']', img, re.IGNORECASE)
if not alt_match:
assert False, f"Image missing alt attribute on {path}"
def check_link_validity(html, path="/"):
"""检查链接有效性"""
links = re.findall(r'<a[^>]+href=["\'](.*?)["\'][^>]*>', html, re.IGNORECASE)
# Check for broken internal links
broken = []
for link in links[:20]: # Check first 20 links
if link.startswith('#') or link.startswith('mailto:') or link.startswith('tel:'):
continue
if link.startswith('http'):
continue
try:
req = Request(f"{BASE_URL}{link}", headers={"User-Agent": "Hermes-Test/1.0"})
resp = urlopen(req, timeout=5)
if resp.status >= 400:
broken.append(link)
except:
pass
assert len(broken) == 0, f"Broken links found: {broken}"
# ==================== TESTS ====================
print("=" * 60)
print("🧪 ai-learning-platform (yuzhiran.com) 浏览器自动化测试")
print("=" * 60)
# 1. 首页 SEO
print("\n📄 首页 SEO 测试")
run_test("首页 SEO 优化", lambda: check_meta_tags(*fetch_page("/")))
run_test("首页结构化数据", lambda: check_structured_data(*fetch_page("/")))
run_test("首页性能检查", lambda: check_performance(*fetch_page("/")))
run_test("首页语义化 HTML", lambda: check_semantic_html(*fetch_page("/")))
run_test("首页图片优化", lambda: check_image_optimization(*fetch_page("/")))
run_test("首页链接有效性", lambda: check_link_validity(*fetch_page("/")))
# 2. 重要页面 SEO
print("\n📄 重要页面 SEO 测试")
important_pages = ["/tools", "/courses", "/skills", "/prompts", "/practices", "/sandbox", "/about"]
for page in important_pages:
run_test(f"{page} SEO 优化", lambda p=page: check_meta_tags(*fetch_page(p)))
run_test(f"{page} 结构化数据", lambda p=page: check_structured_data(*fetch_page(p)))
# 3. robots.txt
print("\n🤖 robots.txt 测试")
def test_robots():
html, resp = fetch_page("/robots.txt")
assert "User-agent" in html, "robots.txt missing User-agent"
assert "Disallow" in html, "robots.txt missing Disallow rules"
assert "Sitemap" in html, "robots.txt missing Sitemap"
assert "/admin/" in html, "robots.txt should disallow /admin/"
assert "/my/" in html, "robots.txt should disallow /my/"
test_robots()
# 4. sitemap.xml
print("\n🗺️ sitemap.xml 测试")
def test_sitemap():
html, resp = fetch_page("/sitemap.xml")
assert "<urlset" in html, "Invalid sitemap format"
assert "<loc>" in html, "Sitemap missing <loc> entries"
url_count = len(re.findall(r'<loc>', html))
assert url_count > 10, f"Sitemap has only {url_count} URLs"
test_sitemap()
# 5. PWA manifest
print("\n📱 PWA manifest 测试")
def test_manifest():
html, resp = fetch_page("/site.webmanifest")
data = json.loads(html)
assert "name" in data, "Manifest missing name"
assert "start_url" in data, "Manifest missing start_url"
assert "icons" in data, "Manifest missing icons"
assert "theme_color" in data, "Manifest missing theme_color"
assert "shortcuts" in data, "Manifest missing shortcuts"
test_manifest()
# 6. 页面加载性能
print("\n⚡ 页面加载性能测试")
def test_homepage_load():
start = time.time()
html, resp = fetch_page("/")
load_time = time.time() - start
assert load_time < 5, f"Homepage took {load_time:.2f}s to load"
print(f" Load time: {load_time:.2f}s")
test_homepage_load()
def test_tools_load():
start = time.time()
html, resp = fetch_page("/tools")
load_time = time.time() - start
assert load_time < 5, f"/tools took {load_time:.2f}s to load"
print(f" Load time: {load_time:.2f}s")
test_tools_load()
# 7. 移动端适配
print("\n📱 移动端适配测试")
def test_mobile_viewport():
html, resp = fetch_page("/")
vp_match = re.search(r'<meta[^>]+name=["\']viewport["\'][^>]+content=["\'](.*?)["\'][^>]*/?>', html, re.DOTALL | re.IGNORECASE)
assert vp_match, "Missing viewport meta tag"
vp = vp_match.group(1).strip()
assert "initial-scale=1" in vp.lower(), "Viewport missing initial-scale"
test_mobile_viewport()
# 8. 暗黑模式支持
print("\n🌙 暗黑模式测试")
def test_dark_mode():
html, resp = fetch_page("/")
# Check for dark mode CSS variables or class
dark_match = re.search(r'dark:', html)
assert dark_match, "No dark mode CSS found"
test_dark_mode()
# 9. 国际化支持
print("\n🌐 国际化测试")
def test_i18n():
html, resp = fetch_page("/")
# Check for lang attribute
lang_match = re.search(r'<html[^>]+lang=["\']zh-CN["\']', html, re.IGNORECASE)
assert lang_match, "Missing lang='zh-CN' attribute"
test_i18n()
# 10. 安全头检查
print("\n🔒 安全头检查")
def test_security_headers():
html, resp = fetch_page("/")
# Check if HTTPS
assert BASE_URL.startswith("https"), "Not using HTTPS"
# Check for CSP (optional but good)
# Check for HSTS (optional)
print(" HTTPS: ✅")
test_security_headers()
# ==================== SUMMARY ====================
print("\n" + "=" * 60)
print("📊 测试总结")
print("=" * 60)
passed = sum(1 for r in results if r["status"] == "PASS")
failed = sum(1 for r in results if r["status"] == "FAIL")
errors = sum(1 for r in results if r["status"] == "ERROR")
total = len(results)
print(f"\n总计: {total} 项测试")
print(f" ✅ 通过: {passed}")
print(f" ❌ 失败: {failed}")
print(f" ⚠️ 错误: {errors}")
print(f" 📈 通过率: {passed/total*100:.1f}%")
if failed > 0:
print("\n❌ 失败的测试:")
for r in results:
if r["status"] == "FAIL":
print(f" - {r['name']}: {r['error']}")
if errors > 0:
print("\n⚠️ 出错的测试:")
for r in results:
if r["status"] == "ERROR":
print(f" - {r['name']}: {r['error']}")
sys.exit(0 if failed == 0 and errors == 0 else 1)