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