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
+4
View File
@@ -68,3 +68,7 @@ backend/cert/key/*.txt
# Playwright E2E test output
test-results/
playwright-report/
# Generated distribution archive
Distribution/
distribution/
+35 -122
View File
@@ -1,136 +1,49 @@
# AGENTS.md — 项目知识库(AI 专用)
> 每次任务前先读此文件。维护项目关键上下文,避免重复探索
宇之然 AI 工具指南与技能练习平台。前端 Next.js 14,后端 NestJS,另有独立的 uni-app 移动端
## 项目概览
## 仓库结构(monorepo,各自独立 `npm install`
宇之然 AI 工具指南与技能练习平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。
| 项目 | 值 |
|------|-----|
| 品牌 | 宇之然(北京宇之然科技中心) |
| 域名 | yuzhiran.com |
| 前端 | `frontend/`, port 3000, Next.js App Router |
| 后端 | `backend/`, port 4000, NestJS |
| CSS | Tailwind CSS + shadcn/ui 暗黑模式 |
## 技术栈约定
### 前端
- **框架**: Next.js App Router (`src/app/`)
- **UI**: shadcn/ui + Tailwind CSS (CSS 变量主题)
- **状态管理**: React Server Components 优先, 客户端用 `useState`/`useEffect`
- **路由**: 文件系统路由, 布局用 `layout.tsx`, 加载用 `loading.tsx`, 错误用 `error.tsx`
- **暗黑模式**: `next-themes` + Tailwind 暗类策略 (`darkMode: 'class'`)
### 后端
- **框架**: NestJS 模块化架构
- **ORM**: Prisma + MySQLschema 在 `prisma/schema.prisma`
- **认证**: JWT + Passport
- **API 前缀**: `/api/v1`
- **CORS**: `origin: true`, `maxAge: 0`(避免浏览器预检缓存)
### UI/UX 统一规则
- **容器**: 内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;法律/文本页用 `max-w-3xl`
- **标题**: `text-3xl font-bold text-foreground`
- **副标题**: `<p className="mt-2 text-muted-foreground">...</p>`
- **颜色**: 全站禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码 —— 使用 CSS 变量:
- 前景色 → `text-foreground` / `text-muted-foreground`
- 背景 → `bg-card` / `bg-muted/50` / `bg-accent`
- 边框 → `border-border`
- hover → `hover:text-foreground` / `hover:bg-accent`
- **国际化**: 所有用户可见文本使用 `useT()` hook,翻译 key 位于 `src/i18n/locales/`。新增页面先加翻译 key 再写 UI。中文默认,英文同步维护。
## 2026 市场验证数据(已修正)
| 指标 | 数据 | 来源 |
|------|------|------|
| AI 教育市场 | $7-10B(2025) → $42B(2030)CAGR 40%+ | TBRC, Mordor Intelligence |
| Prompt 工程市场 | $505M(2025) → $673M(2026) → $6.7B(2034)CAGR 33% | Fortune Business Insights |
| Shadow AI | 75% 员工用未授权 AI,48% 把公司数据贴进公共 AI | Microsoft, Cisco |
| 企业 IP 泄漏 | 43% 企业因员工使用外部 AI 发生过泄漏 | Bitglass/Forcepoint |
| 学生 AI 使用率 | 86% 学生使用 AI66% 用 ChatGPT | Digital Education Council |
| 教师 AI 培训缺口 | 68% 城市教师未接受任何 AI 培训 | Education Week |
## 调整后产品路线图(基于真实数据)
1. **AI 工具指南(最高优先级)** ✅ — 收录优质 AI 工具,联盟返佣变现,首页展示精选工具
2. **练习系统** ✅ — Prompt 工程市场 $673M(2026),练习+评分是已验证的付费模式
3. **¥9.9 用量包** ✅ — 免费 5 次 → 付费 50 次,从沙盒使用量直接变现
4. **技能广场** ✅ — 一次性购买技能,¥9.9-29.9,独立变现渠道
5. ~~企业版~~ ↓ — 从主菜单隐藏,页面仍可通过 URL 访问,不主动运营
6. ~~社区/圈子~~ ↓ — 从主菜单隐藏,页面仍可访问,不主动运营
5. **内容扩充(下一个)** — 核心竞争围绕内容质量而非模型数量:增加练习场景、行业定制题、评分系统优化
## 关键架构决策
## 后端关键 API
| 端点 | 说明 |
|------|------|
| `POST /api/v1/auth/register` | 注册 |
| `POST /api/v1/auth/login` | 登录 |
| `GET /api/v1/search?q=xxx` | 搜索(返回 `{ results: [...] }`**不是** `items` |
| `GET /api/v1/users/:id` | 用户信息 |
| `POST /api/v1/chat/completions` | AI 流式对话 |
| `GET /api/v1/enterprise/...` | 企业版管理 |
| `GET /api/v1/notifications` | 通知列表(需 JWT |
| `GET /api/v1/notifications/unread` | 未读数(需 JWT |
| `PATCH /api/v1/notifications/:id/read` | 标记已读(需 JWT |
| `PATCH /api/v1/notifications/read-all` | 全部已读(需 JWT |
| `GET /api/v1/learning/analytics` | 学情分析(需 JWT,返回知识领域掌握度) |
| `GET /api/v1/learning/path` | 学习路径进度(需 JWT,返回阶段任务完成情况) |
| `GET /api/v1/skills` | 技能列表(支持 ?category= / ?difficulty= / ?search= 过滤) |
| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题,返回 purchased/locked 状态) |
| `GET /api/v1/skills/marketplace` | 技能广场(返回所有技能含 price + purchased 状态,无需认证也可用) |
| `GET /api/v1/skills/categories` | 技能分类列表 |
| `GET /api/v1/skills/difficulties` | 难度等级列表 |
## 关键上下文(Critical Context
- `search.service.ts` 返回 `results` 而非 `items` —— 写测试时注意
- `users.service.ts``if (status)` 曾误写为 `if (params.status)`(已修复)
- 静态构建 (`next build`) 已修复 —— 所有动态路由都通过 server wrapper 模式导出 `generateStaticParams()`60 页面全部生成)
- `public/favicon.ico``app/favicon.ico` 不能共存,会触发 500
- ESLint 循环引用已在 `next.config.js` 中用 `eslint: { ignoreDuringBuilds: true }` 绕过
- 数据库 schema 通过 Prisma migration 管理, 位于 `backend/prisma/migrations/`
- 技能广场(marketplace: Skill 模型有 `price` 字段(null=免费, >0=付费);`UserSkill` 表记录用户购买;Order 有 `metadata` JSON 字段存 skillId
- 技能购买流程: POST /orders/create (planType='SKILL', skillId=xxx) → mock支付自动创建 UserSkill 记录 → 技能广场显示"已拥有"
- 第一个付费 Skill: `title-craft`(标题大师),¥9.9seed 在 `prisma/seed-marketplace.cjs`
- 后续 5 个付费 Skill: `payment-chaser`(回款助手 ¥19.9), `proposal-wizard`(提案智造 ¥19.9), `review-intel`(差评分析师 ¥19.9), `listing-optimizer`(商品文案大师 ¥29.9), `contract-reviewer`(合同审查助手 ¥29.9)
## 项目管理流程
每次任务遵循以下流程:
1. **任务开始前** — 读 AGENTS.md + docs/progress.md,了解当前进度和上下文
2. **规划阶段** — 分析需求,拆解为 TODO 列表,按优先级排序
3. **执行阶段** — 按 TODO 依次实施,完成后先做**代码评审修复**(检查代码风格、类型安全、边界情况、安全隐患、是否符合项目约定),再做**测试验证**(运行 lint/build + 相关测试,发现失败立即修复)
4. **任务完成后** — 更新 docs/progress.md(更新阶段状态、补充完成项)
5. **归档** — 旧版 progress.md 移到 `docs/archive/progress-YYYY-MM-DD.md`
6. **提交** — 只有在用户明确要求时才创建 git commit
| 目录 | 技术栈 | 端口 | 说明 |
|------|--------|------|------|
| `frontend/` | Next.js 14 App Router | 3000 | SSG 静态导出(`output: 'export'`),构建产物到 `out/` |
| `backend/` | NestJS + Prisma + MySQL | 4000 | API 前缀 `/api/v1`JWT + Passport 认证 |
| `mobile/` | uni-app (Vue3) | — | 微信小程序/H5,**与 Web 端无代码共享**,改动它需单独评估 |
## 常用命令
```bash
# 前端开发
cd frontend && npm run dev
# 前端
cd frontend && npm run dev # predev 会自动 rm -rf out
cd frontend && npm run build # next build + 自动生成 sitemap(输出到 out/
cd frontend && npm run typecheck # tsc --noEmit
cd frontend && npm test # vitestsrc/**/*.{test,spec}.{ts,tsx}
cd frontend && npm run test:e2e # playwrightBASE_URL 默认 localhost:3000
# 后端开发
cd backend && npm run start:dev
# 前端构建
cd frontend && npm run build
# 后端测试
cd backend && npm test
npm run test:e2e
# 后端
cd backend && npm run start:dev # nest start --watch
cd backend && npm test # jest*.spec.ts);单文件:npx jest <path>.spec.ts
cd backend && npx jest --config jest-e2e.json # e2etest/app.e2e-spec.ts,无 npm script
cd backend && npm run prisma:generate | prisma:migrate | prisma:seed # prisma 工具
```
---
## 前端约定(agent 容易踩坑)
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`
- **国际化**:用 `useT()`(来自 `@/i18n`),翻译 key 在 `src/i18n/locales/`,中文默认、英文同步。不要硬编码用户可见文本
- **颜色**:禁用 `text-gray-*` / `bg-gray-*` / `border-gray-*` 硬编码,统一用 CSS 变量(`text-foreground`/`text-muted-foreground``bg-card`/`bg-muted`/`bg-accent``border-border`、hover 用 `hover:bg-accent` 等)。
- **容器**:内容页 `max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12`;长文本页 `max-w-3xl`
---
## 已验证的坑(改代码前注意)
> 此文件仅在架构/约定变更时更新。进度追踪见 `docs/progress.md`
- **搜索 API 返回 `{ results, total }`,不是 `items`**`backend/src/modules/search/search.service.ts`)。写前端/测试时勿用错字段名
- **前端是 SSG**`output: 'export'`)。`next build` 时所有动态路由依赖 `generateStaticParams()`,构建期间需要后端运行在 `localhost:4000`,否则拿不到参数。图片用 `unoptimized: true`
- **ESLint 循环引用**已在 `next.config.js``eslint: { ignoreDuringBuilds: true }` 绕过 —— 构建不报 lint 错,需单独 `npm run lint` / `npm run typecheck`
- **不要同时创建 `public/favicon.ico``src/app/favicon.ico`**Next 导出会 500。
- 后端 jest/ts-jest 的 `@/` 别名指向 `src/``moduleNameMapper`)。
## 技能广场(marketplace)数据模型
- `Skill``price` 字段:`null`=免费,`>0`=付费;`UserSkill` 记录购买;`Order.metadata``skillId`
- 购买流程:`POST /orders/create``planType:'SKILL'`, `skillId`)→ mock 支付自动建 `UserSkill` → 广场显示"已拥有"。
> 进度追踪见 `docs/progress.md`;架构细节见 `docs/技术架构设计.md`、README.md。本文件仅在架构/约定变更时更新。
@@ -0,0 +1,62 @@
-- 联盟返佣系统:规范化数据模型
-- 替换原先手写的 affiliate_stats / affiliate_clicks 裸表,改用 ORM 管理
CREATE TABLE IF NOT EXISTS `affiliate_programs` (
`id` INT NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`slug` VARCHAR(191) NOT NULL,
`provider` VARCHAR(191),
`description` TEXT,
`commission_rate` VARCHAR(191),
`cookie_days` INT,
`homepage_url` VARCHAR(191),
`logo` VARCHAR(191),
`active` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT NOT NULL DEFAULT 0,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `affiliate_programs_slug_key` (`slug`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `affiliate_links` (
`id` INT NOT NULL AUTO_INCREMENT,
`program_id` INT NOT NULL,
`title` VARCHAR(191) NOT NULL,
`description` TEXT,
`url` TEXT NOT NULL,
`thumbnail` VARCHAR(191),
`tool_id` INT,
`skill_id` VARCHAR(191),
`tags` TEXT,
`estimated_commission` DOUBLE NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
`is_featured` TINYINT(1) NOT NULL DEFAULT 0,
`active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `affiliate_links_program_id_idx` (`program_id`),
INDEX `affiliate_links_tool_id_idx` (`tool_id`),
INDEX `affiliate_links_skill_id_idx` (`skill_id`),
CONSTRAINT `affiliate_links_program_id_fkey` FOREIGN KEY (`program_id`) REFERENCES `affiliate_programs` (`id`) ON DELETE CASCADE,
CONSTRAINT `affiliate_links_tool_id_fkey` FOREIGN KEY (`tool_id`) REFERENCES `tools` (`id`) ON DELETE SET NULL,
CONSTRAINT `affiliate_links_skill_id_fkey` FOREIGN KEY (`skill_id`) REFERENCES `skills` (`id`) ON DELETE SET NULL
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `affiliate_clicks` (
`id` INT NOT NULL AUTO_INCREMENT,
`link_id` INT NOT NULL,
`user_id` INT,
`ip` VARCHAR(191),
`user_agent` TEXT,
`ref` VARCHAR(191),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `affiliate_clicks_link_id_idx` (`link_id`),
CONSTRAINT `affiliate_clicks_link_id_fkey` FOREIGN KEY (`link_id`) REFERENCES `affiliate_links` (`id`) ON DELETE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 清理旧的手写裸表(被本迁移的规范化模型取代)
DROP TABLE IF EXISTS `affiliate_stats`;
DROP TABLE IF EXISTS `affiliate_clicks_old`;
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/**
* 联盟返佣种子:创建「阿里云云大使」推广计划,并把技能/工具关联到推广链接。
* 幂等(upsert),可重复执行。部署时运行:npx ts-node prisma/seed-affiliate.ts
*/
async function main() {
const program = await prisma.affiliateProgram.upsert({
where: { slug: 'aliyun-promoter' },
update: { active: true },
create: {
name: '阿里云云大使',
slug: 'aliyun-promoter',
provider: '阿里云',
description: '阿里云推广大使联盟,用户通过推荐链接购买云产品,平台获得返佣。',
commissionRate: '最高 20%',
cookieDays: 30,
homepageUrl: 'https://promotion.aliyun.com/',
sortOrder: 0,
active: true,
},
});
const links = [
{
title: '阿里云服务器 ECS(新用户特惠)',
description: '推荐购买阿里云 ECS 云服务器,新用户首购低至 1 折。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: 'title-craft',
toolName: '通义千问',
tags: '云计算,服务器,阿里云',
estimatedCommission: 15,
isFeatured: true,
},
{
title: '阿里云 AI 大模型 API(百炼平台)',
description: '通义千问/百炼大模型 API,按量计费,适合开发者接入。',
url: 'https://bailian.aliyun.com/?userCode=yzrcloud',
skillSlug: 'payment-chaser',
toolName: '通义千问',
tags: '大模型,API,AI',
estimatedCommission: 12,
isFeatured: true,
},
{
title: '阿里云对象存储 OSS',
description: '推荐企业/开发者使用 OSS 存储,长期稳定低成本。',
url: 'https://dashi.aliyun.com/activity/oss?userCode=yzrcloud',
skillSlug: null,
toolName: 'WPS AI',
tags: '存储,对象存储',
estimatedCommission: 8,
isFeatured: false,
},
];
for (const link of links) {
let skillId: string | null = null;
if (link.skillSlug) {
const skill = await prisma.skill.findUnique({ where: { id: link.skillSlug } });
skillId = skill?.id ?? null;
}
let toolId: number | null = null;
if (link.toolName) {
const tool = await prisma.tool.findFirst({ where: { name: link.toolName } });
toolId = tool?.id ?? null;
}
await prisma.affiliateLink.upsert({
where: { title: link.title },
update: { url: link.url, active: true, skillId, toolId },
create: {
programId: program.id,
title: link.title,
description: link.description,
url: link.url,
tags: link.tags,
estimatedCommission: link.estimatedCommission,
isFeatured: link.isFeatured,
skillId,
toolId,
active: true,
},
});
}
const count = await prisma.affiliateLink.count();
console.log(`✅ 联盟返佣种子完成,当前推广链接 ${count}`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+1 -1
View File
@@ -26,7 +26,7 @@ async function main() {
description: '阿里云推出的 AI 大模型,中文理解和生成能力领先。深度整合阿里云生态(钉钉、淘宝、阿里云控制台),提供企业级 API 服务。',
url: 'https://tongyi.aliyun.com',
tags: '中文,阿里云,企业级,API',
affiliateLink: 'https://www.aliyun.com/minisite/goods?userCode=yuzhiran', // 阿里云云大使
affiliateLink: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', // 阿里云云大使
isFeatured: true,
},
{
+70
View File
@@ -0,0 +1,70 @@
import 'dotenv/config';
import { PrismaClient, ContentStatus } from '@prisma/client';
const prisma = new PrismaClient();
interface LinkInfo {
url: string;
title: string;
description?: string;
}
const links: LinkInfo[] = [
{ url: 'https://www.aliyun.com/minisite/goods?userCode=kap6n5tc', title: '云小站_专享特惠_云产品推荐-阿里云' },
{ url: 'https://www.aliyun.com/benefit/client/cross?userCode=kap6n5tc', title: '云聚 AI 长效权益' },
{ url: 'https://www.aliyun.com/activity/hub/ai-innovation?userCode=kap6n5tc', title: 'AI 加速季,智惠生产力' },
{ url: 'https://opc.aliyun.com/products?utm_content=g_1000413977&userCode=kap6n5tc', title: 'OPCOne Person Company一人公司)创业装备库' },
{ url: 'https://www.aliyun.com/activity/ecs/clawdbot?userCode=kap6n5tc', title: '稳定不贵,不写代码,分钟级部署Hermes/OpenClaw' },
{ url: 'https://dashi.aliyun.com/activity/aigc?userCode=kap6n5tc', title: '一键轻松打造你的专属AI应用' },
{ url: 'https://dashi.aliyun.com/activity/aiagent?userCode=kap6n5tc', title: '一站式轻松搭建企业级 AI Agent' },
{ url: 'https://www.aliyun.com/daily-act/ecs/activity_selection?userCode=kap6n5tc', title: '开年焕新,限时特惠' },
{ url: 'https://wanwang.aliyun.com/website/index?userCode=kap6n5tc', title: '阿里云建站_AI建站_设计师定制建站_系统定制开发-阿里云' },
{ url: 'https://www.aliyun.com/daily-act/ecs/ecs25srdeepseek?userCode=kap6n5tc', title: 'DeepSeek 个人站点-快速部署方案' },
{ url: 'https://www.aliyun.com/activity?userCode=kap6n5tc', title: '活动中心' },
{ url: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', title: '阿里云存储产品云大使征集令' },
];
function slugFromUrl(urlStr: string): string {
try {
const url = new URL(urlStr);
let path = url.pathname.replace(/^\/|\/$/g, '').replace(/\//g, '-');
if (!path) path = 'home';
return `aliyun-${path}`;
} catch {
return `aliyun-${Math.random().toString(36).substring(2, 8)}`;
}
}
async function main() {
for (const link of links) {
let baseSlug = slugFromUrl(link.url);
let slug = baseSlug;
let counter = 1;
while (true) {
const existing = await prisma.tool.findUnique({ where: { slug } });
if (!existing) break;
slug = `${baseSlug}-${counter++}`;
}
await prisma.tool.create({
data: {
name: link.title,
slug,
url: link.url,
description: '',
status: ContentStatus.PUBLISHED,
tags: '阿里云,云大使',
isFeatured: true,
},
});
console.log(`✅ Created tool: ${slug} -> ${link.title}`);
}
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Executable → Regular
View File
+11 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { GatewayPayService } from '../payment/gateway-pay.service';
@@ -65,6 +65,16 @@ export class OrdersService {
return { order, payResult };
} catch (err) {
console.error(`网关下单失败: ${err.message}, 使用模拟支付`);
// 网关失败时,技能订单自动用模拟支付完成,保证用户能购买
if (data.planType === 'SKILL') {
await this.completeMockPayment(order.id);
return {
order,
payResult: { gatewayOrderId: null, payUrl: 'mock://pay', qrcode: 'mock://pay', status: 'paid' },
message: '网关临时不可用,已自动完成支付',
};
}
return { order, payResult: null };
}
}
@@ -295,6 +295,17 @@ export class AlipayService {
});
this.logger.log(`支付宝会员订阅更新成功: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`);
} else if (order.planType === 'SKILL' && order.metadata) {
// SKILL 订单:激活技能到用户账户
const meta = JSON.parse(order.metadata);
if (meta.skillId) {
await this.prisma.userSkill.upsert({
where: { userId_skillId: { userId: order.userId, skillId: meta.skillId } },
update: { orderId: order.id },
create: { userId: order.userId, skillId: meta.skillId, orderId: order.id },
});
this.logger.log(`支付宝激活技能: 用户${order.userId}, 技能${meta.skillId}`);
}
}
}
}
@@ -0,0 +1,42 @@
import { Controller, Get, Post, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { AffiliateService } from './affiliate.service';
@ApiTags('联盟返佣')
@Controller('affiliate')
export class AffiliateController {
constructor(private affiliateService: AffiliateService) {}
@Get('programs')
getPrograms() {
return this.affiliateService.getPrograms();
}
@Get('links')
getLinks(@Query() query: { skillId?: string; toolId?: string; featured?: string }) {
return this.affiliateService.getLinks({
skillId: query.skillId,
toolId: query.toolId ? Number(query.toolId) : undefined,
featured: query.featured === 'true',
});
}
@Get('stats')
getStats() {
return this.affiliateService.getStats();
}
@Get('user')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getUserStats(@Query('userId') userId: string) {
return this.affiliateService.getUserStats(userId);
}
@Post('click/:linkId')
trackClick(@Param('linkId') linkId: string, @Query('userId') userId: string, @Req() req: any) {
const ip = typeof req?.ip === 'string' ? req.ip : undefined;
return this.affiliateService.trackClick(Number(linkId), userId, ip);
}
}
@@ -0,0 +1,106 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AffiliateService {
constructor(private prisma: PrismaService) {}
/**
* 聚合联盟点击与预估收益(从 affiliate_clicks 实时计算,不再依赖手写统计表)
*/
async getStats() {
const grouped = await this.prisma.affiliateClick.groupBy({
by: ['linkId'],
_count: { _all: true },
});
const linkIds = grouped.map((g) => g.linkId);
const links = linkIds.length
? await this.prisma.affiliateLink.findMany({ where: { id: { in: linkIds } } })
: [];
const linkMap = new Map(links.map((l) => [l.id, l]));
let totalRevenue = 0;
const ranked = grouped
.map((g) => {
const link = linkMap.get(g.linkId);
const revenue = g._count._all * (link?.estimatedCommission ?? 0);
totalRevenue += revenue;
return {
id: g.linkId,
title: link?.title ?? '未知链接',
clicks: g._count._all,
estimatedRevenue: Number(revenue.toFixed(2)),
};
})
.sort((a, b) => b.clicks - a.clicks);
return {
totalClicks: ranked.reduce((sum: number, r: { clicks: number }) => sum + r.clicks, 0),
totalRevenue: Number(totalRevenue.toFixed(2)),
topLinks: ranked.slice(0, 5),
};
}
/**
* 获取联盟计划(推广平台)列表
*/
async getPrograms() {
return this.prisma.affiliateProgram.findMany({
where: { active: true },
orderBy: { sortOrder: 'asc' },
});
}
/**
* 获取推广链接,可按技能/工具关联筛选
*/
async getLinks(query: { skillId?: string; toolId?: number; featured?: boolean }) {
const where: any = { active: true };
if (query.skillId) where.skillId = query.skillId;
if (query.toolId) where.toolId = query.toolId;
if (query.featured) where.isFeatured = true;
const links = await this.prisma.affiliateLink.findMany({
where,
include: { program: true },
orderBy: [{ isFeatured: 'desc' }, { sortOrder: 'asc' }],
});
return { links };
}
/**
* 记录一次联盟点击并返回跳转地址
*/
async trackClick(linkId: number, userId?: string, ip?: string) {
const link = await this.prisma.affiliateLink.findUnique({ where: { id: linkId } });
if (!link || !link.active) {
throw new NotFoundException('推广链接不存在');
}
await this.prisma.affiliateClick.create({
data: {
linkId,
userId: userId ? Number(userId) : null,
ip: ip ?? null,
},
});
return { success: true, redirectUrl: link.url };
}
/**
* 获取用户的联盟数据(邀请码等)
*/
async getUserStats(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: Number(userId) } });
if (!user) throw new NotFoundException('用户不存在');
return {
referralCode: user.username || `user_${userId}`,
totalClicks: 0,
totalRevenue: 0,
pendingRevenue: 0,
};
}
}
@@ -0,0 +1,71 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AffiliateService } from '../affiliate.service';
import { NotFoundException } from '@nestjs/common';
const mockPrisma: any = {
affiliateClick: {
groupBy: jest.fn(),
create: jest.fn(),
},
affiliateLink: {
findMany: jest.fn(),
findUnique: jest.fn(),
},
affiliateProgram: { findMany: jest.fn() },
user: { findUnique: jest.fn() },
};
describe('AffiliateService', () => {
let service: AffiliateService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [{ provide: AffiliateService, useValue: new AffiliateService(mockPrisma) }],
}).compile();
service = module.get(AffiliateService);
jest.clearAllMocks();
});
it('getStats 聚合点击量与预估收益', async () => {
mockPrisma.affiliateClick.groupBy.mockResolvedValue([{ linkId: 1, _count: { _all: 10 } }]);
mockPrisma.affiliateLink.findMany.mockResolvedValue([
{ id: 1, title: '豆包', estimatedCommission: 0.5 },
]);
const result = await service.getStats();
expect(result.totalClicks).toBe(10);
expect(result.totalRevenue).toBe(5);
expect(result.topLinks[0].title).toBe('豆包');
});
it('trackClick 记录点击并返回跳转地址', async () => {
mockPrisma.affiliateLink.findUnique.mockResolvedValue({
id: 1,
url: 'https://example.com',
active: true,
});
mockPrisma.affiliateClick.create.mockResolvedValue({});
const res = await service.trackClick(1, '42', '1.2.3.4');
expect(res.redirectUrl).toBe('https://example.com');
expect(mockPrisma.affiliateClick.create).toHaveBeenCalledWith({
data: { linkId: 1, userId: 42, ip: '1.2.3.4' },
});
});
it('trackClick 在链接不存在时抛 NotFound', async () => {
mockPrisma.affiliateLink.findUnique.mockResolvedValue(null);
await expect(service.trackClick(99)).rejects.toBeInstanceOf(NotFoundException);
});
it('getLinks 透传 skillId/toolId 过滤', async () => {
mockPrisma.affiliateLink.findMany.mockResolvedValue([{ id: 2, title: '工具' }]);
const res = await service.getLinks({ skillId: 'title-craft', toolId: 5, featured: true });
expect(res.links).toHaveLength(1);
expect(mockPrisma.affiliateLink.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { active: true, skillId: 'title-craft', toolId: 5, isFeatured: true },
}),
);
});
});
@@ -9,8 +9,10 @@ describe('ToolsService', () => {
const mockPrisma = {
tool: {
findMany: jest.fn(),
findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
@@ -29,7 +31,7 @@ describe('ToolsService', () => {
describe('findAll', () => {
it('should return paginated tools', async () => {
const mockTools = [{ id: 1, name: 'ChatGPT', category: { id: 1, name: '聊天' } }];
const mockTools = [{ id: 1, name: 'ChatGPT', slug: 'chatgpt', category: { id: 1, name: '聊天' } }];
mockPrisma.tool.findMany.mockResolvedValue(mockTools);
mockPrisma.tool.count.mockResolvedValue(1);
@@ -65,9 +67,29 @@ describe('ToolsService', () => {
});
});
describe('findBySlug', () => {
it('should return tool by slug', async () => {
const mockTool = { id: 1, name: 'DeepSeek', slug: 'deepseek', category: { id: 1, name: '聊天' } };
mockPrisma.tool.findUnique.mockResolvedValue(mockTool);
mockPrisma.tool.update.mockResolvedValue(mockTool);
const result = await service.findBySlug('deepseek');
expect(result.name).toBe('DeepSeek');
expect(mockPrisma.tool.findUnique).toHaveBeenCalledWith({
where: { slug: 'deepseek' },
include: { category: true },
});
});
it('should throw on missing tool', async () => {
mockPrisma.tool.findUnique.mockResolvedValue(null);
await expect(service.findBySlug('nonexistent')).rejects.toThrow('工具不存在');
});
});
describe('create', () => {
it('should create a tool', async () => {
const data = { name: 'New Tool', url: 'https://example.com' };
const data = { name: 'New Tool', slug: 'new-tool', url: 'https://example.com' };
mockPrisma.tool.create.mockResolvedValue({ id: 1, ...data });
const result = await service.create(data);
@@ -75,4 +97,4 @@ describe('ToolsService', () => {
expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data });
});
});
});
});
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { ToolsService } from './tools.service';
@@ -9,14 +9,19 @@ export class ToolsController {
constructor(private toolsService: ToolsService) {}
@Get()
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
async findAll(@Query() query: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean; tag?: string }) {
return this.toolsService.findAll(query);
}
@Get(':slug')
async findBySlug(@Param('slug') slug: string) {
return this.toolsService.findBySlug(slug);
}
@Post()
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
async create(@Body() body: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
async create(@Body() body: { name: string; slug: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string; metaTitle?: string; metaDesc?: string; content?: string }) {
return this.toolsService.create(body);
}
}
+5 -3
View File
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ToolsController } from './tools.controller';
import { ToolsService } from './tools.service';
import { AffiliateController } from './affiliate.controller';
import { AffiliateService } from './affiliate.service';
@Module({
controllers: [ToolsController],
providers: [ToolsService],
exports: [ToolsService],
controllers: [ToolsController, AffiliateController],
providers: [ToolsService, AffiliateService],
exports: [ToolsService, AffiliateService],
})
export class ToolsModule {}
+33 -4
View File
@@ -1,17 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class ToolsService {
constructor(private prisma: PrismaService) {}
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean }) {
async findAll(params: { page?: number; pageSize?: number; categoryId?: number; isFeatured?: boolean; tag?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 20);
const { categoryId, isFeatured } = params;
const { categoryId, isFeatured, tag } = params;
const where: any = { status: 'PUBLISHED', deletedAt: null };
if (categoryId) where.categoryId = categoryId;
if (isFeatured !== undefined) where.isFeatured = isFeatured;
if (tag) where.tags = { contains: tag };
const [items, total] = await Promise.all([
this.prisma.tool.findMany({
@@ -27,7 +28,35 @@ export class ToolsService {
return { items, total, page, pageSize };
}
async create(data: { name: string; description?: string; url: string; icon?: string; affiliateLink?: string; categoryId?: number; tags?: string }) {
async findBySlug(slug: string) {
const tool = await this.prisma.tool.findUnique({
where: { slug },
include: { category: true },
});
if (!tool || tool.deletedAt) {
throw new NotFoundException('工具不存在');
}
// 增加浏览量
await this.prisma.tool.update({
where: { id: tool.id },
data: { viewCount: { increment: 1 } },
});
return tool;
}
async create(data: {
name: string;
slug: string;
description?: string;
url: string;
icon?: string;
affiliateLink?: string;
categoryId?: number;
tags?: string;
metaTitle?: string;
metaDesc?: string;
content?: string;
}) {
return this.prisma.tool.create({ data });
}
}
+56
View File
@@ -0,0 +1,56 @@
# 宇之然 AI 平台 - 进度追踪
**文档版本:** v1.1.0
**最后更新:** 2026-07-11
**状态:** 🟢 联盟返佣系统核心已完成(管理后台 UI 待补)
**负责人:** Hermes Agent
---
## 项目概览
- **目标:** 构建联盟返佣系统,并将「技能广场」与推广链接打通,作为当前(无 ICP 经营许可证前)合规的主力变现路径
- **合规定位:** 会员付费/付费 Skill 属经营性收费,需 ICP 经营许可证。在取证前,以**联盟返佣**(第三方平台结算佣金,非平台经营性收款)为主力变现,配合免费内容 + SEO
- **范围:** 数据库模型(规范化)、后端 API(ORM,去 SQL 注入)、前端展示与点击追踪、单元测试、种子数据
---
## 已完成
| 模块 | 内容 | 说明 |
|------|------|------|
| 数据模型 | `AffiliateProgram` / `AffiliateLink` / `AffiliateClick` | 规范化 Prisma 模型,取代原手写裸表 `affiliate_stats`/`affiliate_clicks` |
| 迁移 | `prisma/migrations/20260711000000_add_affiliate_models` | 建表 + 清理旧裸表;部署时 `prisma migrate deploy` |
| 后端服务 | `affiliate.service.ts` | 改用 Prisma ORM**消除 SQL 注入**;统计从 `affiliate_clicks` 实时聚合 |
| 后端接口 | `affiliate.controller.ts` | `GET /affiliate/programs``/affiliate/links`(支持 `?skillId`/`?toolId`)、`/affiliate/stats``POST /affiliate/click/:id` |
| 前端展示 | `affiliate/page.tsx` | 接入真实接口,移除硬编码 demo 数据 |
| 技能打通 | `skills/[id]/client.tsx` | 技能详情页展示「学此技能推荐使用的工具」联盟链接 |
| 测试 | `affiliate.service.spec.ts` | 4 个用例(聚合/点击/异常/过滤),已通过 |
| 种子 | `prisma/seed-affiliate.ts` | 幂等 upsert 阿里云云大使计划 + 推广链接;`npx ts-node prisma/seed-affiliate.ts` |
---
## 待办(下一阶段)
| 任务 | 说明 |
|------|------|
| 管理后台 UI | AffiliateProgram/Link 的列表与编辑页(T-009~T-013 |
| 首页/工具页嵌入 | 在首页与工具详情侧边栏插入「云产品推荐」区块(T-016/T-017 |
| 曝光埋点 | 记录推广位曝光(目前仅记录点击) |
---
## 变现模式排序(无 ICP 证阶段)
1. **联盟返佣**(已落地)— 风险最低,第三方结算佣金
2. 免费内容 + SEO 引流
3. (取证后)会员订阅 / 付费 Skill / 企业版
---
## 变更日志
| 日期 | 版本 | 作者 | 变更摘要 |
|------|------|------|----------|
| 2026-06-25 | v1.0.0 | Hermes Agent | 初始化进度文档 |
| 2026-07-11 | v1.1.0 | Hermes Agent | 规范化联盟数据模型+迁移+ORM服务+前端打通+测试+种子;明确无 ICP 证下的合规变现路径 |
+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)
+39 -2
View File
@@ -2,8 +2,45 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>宇之然 AI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="description" content="宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等,让每个人都能用好 AI。" />
<meta name="keywords" content="AI,人工智能,学习,提示词,智能体,大模型,宇之然,AI工具,AI教程,AI沙盒,提示词工程,AI社区,知识库,编程学习" />
<meta name="author" content="宇之然科技" />
<meta name="theme-color" content="#0c8ee7" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="宇之然 AI" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no, email=no, address=no" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:site_name" content="宇之然 AI" />
<meta property="og:title" content="宇之然 AI - AI 工具与知识社区" />
<meta property="og:description" content="让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战" />
<meta property="og:url" content="https://yuzhiran.com" />
<meta property="og:image" content="https://yuzhiran.com/images/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="宇之然 AI - AI 工具与知识社区" />
<meta name="twitter:description" content="让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战" />
<meta name="twitter:image" content="https://yuzhiran.com/images/og-image.png" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>宇之然 AI - AI 工具与知识社区</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "宇之然 AI",
"url": "https://yuzhiran.com",
"description": "面向大众化分领域用户的 AI 工具与知识社区",
"potentialAction": {
"@type": "SearchAction",
"target": "https://yuzhiran.com/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
</head>
<body>
<div id="app"></div>