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
|
# Playwright E2E test output
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
|
||||||
|
# Generated distribution archive
|
||||||
|
Distribution/
|
||||||
|
distribution/
|
||||||
|
|||||||
@@ -1,136 +1,49 @@
|
|||||||
# AGENTS.md — 项目知识库(AI 专用)
|
# AGENTS.md — 项目知识库(AI 专用)
|
||||||
|
|
||||||
> 每次任务前先读此文件。维护项目关键上下文,避免重复探索。
|
宇之然 AI 工具指南与技能练习平台。前端 Next.js 14,后端 NestJS,另有独立的 uni-app 移动端。
|
||||||
|
|
||||||
## 项目概览
|
## 仓库结构(monorepo,各自独立 `npm install`)
|
||||||
|
|
||||||
宇之然 AI 工具指南与技能练习平台。前端 Next.js + shadcn/ui,后端 NestJS + MySQL。
|
| 目录 | 技术栈 | 端口 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
| 项目 | 值 |
|
| `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 端无代码共享**,改动它需单独评估 |
|
||||||
| 域名 | 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
|
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```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 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 frontend && npm run build
|
cd backend && npm run prisma:generate | prisma:migrate | prisma:seed # prisma 工具
|
||||||
|
|
||||||
# 后端测试
|
|
||||||
cd backend && npm test
|
|
||||||
npm run test:e2e
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 前端约定(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 服务。',
|
description: '阿里云推出的 AI 大模型,中文理解和生成能力领先。深度整合阿里云生态(钉钉、淘宝、阿里云控制台),提供企业级 API 服务。',
|
||||||
url: 'https://tongyi.aliyun.com',
|
url: 'https://tongyi.aliyun.com',
|
||||||
tags: '中文,阿里云,企业级,API',
|
tags: '中文,阿里云,企业级,API',
|
||||||
affiliateLink: 'https://www.aliyun.com/minisite/goods?userCode=yuzhiran', // 阿里云云大使
|
affiliateLink: 'https://dashi.aliyun.com/activity/oss?userCode=kap6n5tc', // 阿里云云大使
|
||||||
isFeatured: true,
|
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 { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { GatewayPayService } from '../payment/gateway-pay.service';
|
import { GatewayPayService } from '../payment/gateway-pay.service';
|
||||||
|
|
||||||
@@ -65,6 +65,16 @@ export class OrdersService {
|
|||||||
|
|
||||||
return { order, payResult };
|
return { order, payResult };
|
||||||
} catch (err) {
|
} 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 };
|
return { order, payResult: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,17 @@ export class AlipayService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`支付宝会员订阅更新成功: 用户${order.userId}, 类型${order.planType}, 到期${subscriptionEndDate}`);
|
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 = {
|
const mockPrisma = {
|
||||||
tool: {
|
tool: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
count: jest.fn(),
|
count: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +31,7 @@ describe('ToolsService', () => {
|
|||||||
|
|
||||||
describe('findAll', () => {
|
describe('findAll', () => {
|
||||||
it('should return paginated tools', async () => {
|
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.findMany.mockResolvedValue(mockTools);
|
||||||
mockPrisma.tool.count.mockResolvedValue(1);
|
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', () => {
|
describe('create', () => {
|
||||||
it('should create a tool', async () => {
|
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 });
|
mockPrisma.tool.create.mockResolvedValue({ id: 1, ...data });
|
||||||
|
|
||||||
const result = await service.create(data);
|
const result = await service.create(data);
|
||||||
@@ -75,4 +97,4 @@ describe('ToolsService', () => {
|
|||||||
expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data });
|
expect(mockPrisma.tool.create).toHaveBeenCalledWith({ data });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ToolsService } from './tools.service';
|
import { ToolsService } from './tools.service';
|
||||||
@@ -9,14 +9,19 @@ export class ToolsController {
|
|||||||
constructor(private toolsService: ToolsService) {}
|
constructor(private toolsService: ToolsService) {}
|
||||||
|
|
||||||
@Get()
|
@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);
|
return this.toolsService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':slug')
|
||||||
|
async findBySlug(@Param('slug') slug: string) {
|
||||||
|
return this.toolsService.findBySlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
@ApiBearerAuth()
|
@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);
|
return this.toolsService.create(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ToolsController } from './tools.controller';
|
import { ToolsController } from './tools.controller';
|
||||||
import { ToolsService } from './tools.service';
|
import { ToolsService } from './tools.service';
|
||||||
|
import { AffiliateController } from './affiliate.controller';
|
||||||
|
import { AffiliateService } from './affiliate.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [ToolsController],
|
controllers: [ToolsController, AffiliateController],
|
||||||
providers: [ToolsService],
|
providers: [ToolsService, AffiliateService],
|
||||||
exports: [ToolsService],
|
exports: [ToolsService, AffiliateService],
|
||||||
})
|
})
|
||||||
export class ToolsModule {}
|
export class ToolsModule {}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ToolsService {
|
export class ToolsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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 page = Number(params.page ?? 1);
|
||||||
const pageSize = Number(params.pageSize ?? 20);
|
const pageSize = Number(params.pageSize ?? 20);
|
||||||
const { categoryId, isFeatured } = params;
|
const { categoryId, isFeatured, tag } = params;
|
||||||
const where: any = { status: 'PUBLISHED', deletedAt: null };
|
const where: any = { status: 'PUBLISHED', deletedAt: null };
|
||||||
if (categoryId) where.categoryId = categoryId;
|
if (categoryId) where.categoryId = categoryId;
|
||||||
if (isFeatured !== undefined) where.isFeatured = isFeatured;
|
if (isFeatured !== undefined) where.isFeatured = isFeatured;
|
||||||
|
if (tag) where.tags = { contains: tag };
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.tool.findMany({
|
this.prisma.tool.findMany({
|
||||||
@@ -27,7 +28,35 @@ export class ToolsService {
|
|||||||
return { items, total, page, pageSize };
|
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 });
|
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",
|
"framer-motion": "^12.38.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
@@ -8044,6 +8045,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"marked": "^18.0.5",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
|
|||||||
@@ -1,4 +1,92 @@
|
|||||||
|
# 宇之然 AI - robots.txt
|
||||||
|
# https://yuzhiran.com/robots.txt
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
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
|
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,
|
'/practices': 0.8,
|
||||||
'/contents': 0.8,
|
'/contents': 0.8,
|
||||||
'/prompts': 0.8,
|
'/prompts': 0.8,
|
||||||
|
'/prompts/workshop': 0.7,
|
||||||
'/models': 0.7,
|
'/models': 0.7,
|
||||||
'/sandbox': 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,
|
'/about': 0.5,
|
||||||
'/privacy': 0.3,
|
'/privacy': 0.3,
|
||||||
'/terms': 0.3,
|
'/terms': 0.3,
|
||||||
|
'/ai-agreement': 0.3,
|
||||||
|
'/search': 0.5,
|
||||||
};
|
};
|
||||||
|
|
||||||
function walkDir(dir, basePath = '') {
|
function walkDir(dir, basePath = '') {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: '关于宇之然',
|
title: '关于宇之然',
|
||||||
description: '关于宇之然 AI 学习与实践平台',
|
description: '宇之然 AI 致力于让每个人都能用好 AI。北京宇之然科技中心是一家专注于 AI 技术普及与应用的科技企业,提供 AI 工具指南、课程学习、提示词库、AI 沙盒实战等服务。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AboutPage() {
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { marked } from 'marked';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number; title: string; summary?: string; content?: string; cover?: string;
|
id: number; title: string; summary?: string; content?: string; cover?: string;
|
||||||
contentType: string; tags?: string; authorName?: string; viewCount: number;
|
link?: string; contentType: string; tags?: string; authorName?: string;
|
||||||
isAiGenerated: boolean; publishedAt: string; createdAt: string;
|
viewCount: number; isAiGenerated: boolean; publishedAt: string; createdAt: string;
|
||||||
category?: { name: 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() {
|
export default function ContentDetailClient() {
|
||||||
@@ -18,101 +31,247 @@ export default function ContentDetailClient() {
|
|||||||
const [content, setContent] = useState<Content | null>(null);
|
const [content, setContent] = useState<Content | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [related, setRelated] = useState<Content[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_BASE}/contents/${params.id}`)
|
setError('');
|
||||||
.then(r => r.json())
|
Promise.all([
|
||||||
.then(data => {
|
fetch(`${API_BASE}/contents/${params.id}`).then(r => {
|
||||||
if (!data || !data.id) throw new Error('内容不存在');
|
if (!r.ok) throw new Error('内容不存在');
|
||||||
setContent(data);
|
return r.json();
|
||||||
})
|
}),
|
||||||
.catch(e => setError(e.message))
|
// Fetch related articles from same category
|
||||||
.finally(() => setLoading(false));
|
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]);
|
}, [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 (
|
if (loading) return (
|
||||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<Skeleton className="h-8 w-48 mb-6" />
|
<div className="mb-6">
|
||||||
<Skeleton className="h-4 w-32 mb-8" />
|
<Skeleton className="h-4 w-24 mb-4" />
|
||||||
<Skeleton className="h-64 w-full 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-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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error || !content) return (
|
if (error || !content) return (
|
||||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-2">{error || '内容不存在'}</h1>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div className="mb-8">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3">
|
<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 && (
|
{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 className="text-xs text-muted-foreground">{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
|
||||||
<span>{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString() : new Date(content.createdAt).toLocaleDateString()}</span>
|
<span className="text-xs text-muted-foreground">·</span>
|
||||||
<span>{content.viewCount} 次阅读</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 && (
|
{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>
|
</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 && (
|
{/* Title */}
|
||||||
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground leading-tight mb-4">
|
||||||
{content.cover.startsWith('http') ? (
|
{content.title}
|
||||||
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" />
|
</h1>
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
|
{/* Meta row */}
|
||||||
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="flex items-center gap-4 text-sm text-muted-foreground border-t border-border pt-4">
|
||||||
<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" />
|
{content.authorName && <span>作者:{content.authorName}</span>}
|
||||||
</svg>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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">
|
{/* Article body - rendered markdown */}
|
||||||
{content.content ? (
|
{renderedHtml ? (
|
||||||
<div className="text-foreground leading-relaxed whitespace-pre-wrap text-base">
|
<div className="article-body text-foreground leading-relaxed" dangerouslySetInnerHTML={{ __html: renderedHtml }} />
|
||||||
{content.content}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground italic py-8 text-center">暂无内容</p>
|
<p className="text-muted-foreground italic py-8 text-center">暂无内容</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 pt-8 border-t border-border">
|
{/* Footer */}
|
||||||
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
|
<footer className="mt-12 pt-8 border-t border-border">
|
||||||
← 返回首页
|
{/* Tags */}
|
||||||
</Link>
|
{content.tags && (
|
||||||
</div>
|
<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>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,14 @@ import { Card } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { FileText, Eye } from 'lucide-react';
|
import { FileText, Eye } from 'lucide-react';
|
||||||
|
import { marked } from 'marked';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
|
||||||
interface Content {
|
interface Content {
|
||||||
id: number; title: string; summary: string | null; cover: string | null;
|
id: number; title: string; summary: string | null; cover: string | null;
|
||||||
contentType: string; tags: string | null; authorName: 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() {
|
function ContentSkeleton() {
|
||||||
@@ -29,24 +31,160 @@ function ContentSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContentsPage() {
|
export default function ContentsPage() {
|
||||||
|
const [pathname, setPathname] = useState('');
|
||||||
const [contents, setContents] = useState<Content[]>([]);
|
const [contents, setContents] = useState<Content[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
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`)
|
fetch(`${API_BASE}/contents`)
|
||||||
.then(r => r.json()).then(data => setContents(data.items || []))
|
.then(r => r.json()).then(data => setContents(data.items || []))
|
||||||
.catch(() => {}).finally(() => setLoading(false));
|
.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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<h1 className="text-3xl font-bold text-foreground">内容中心</h1>
|
<h1 className="text-3xl font-bold text-foreground">内容中心</h1>
|
||||||
<p className="mt-2 text-muted-foreground">AI 相关的教程、资讯和深度文章</p>
|
<p className="mt-2 text-muted-foreground">AI 相关的教程、资讯和深度文章</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{[1,2,3].map(i => <ContentSkeleton key={i} />)}
|
{[1,2,3].map(i => <ContentSkeleton key={i} />)}
|
||||||
@@ -83,4 +221,4 @@ export default function ContentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useParams } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import { BookOpen, AlertTriangle, Menu, ChevronLeft, X } from 'lucide-react';
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: number; title: string; content?: string; sortOrder: number; status: string;
|
id: number; title: string; content?: string; sortOrder: number; status: string;
|
||||||
@@ -20,6 +21,51 @@ interface Course {
|
|||||||
createdAt: string;
|
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() {
|
export default function CourseDetailClient() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [course, setCourse] = useState<Course | null>(null);
|
const [course, setCourse] = useState<Course | null>(null);
|
||||||
@@ -28,9 +74,7 @@ export default function CourseDetailClient() {
|
|||||||
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
const [orderNo, setOrderNo] = useState('');
|
|
||||||
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
const [qrCodeUrl, setQrCodeUrl] = useState('');
|
||||||
const [payLoading, setPayLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
@@ -47,22 +91,12 @@ export default function CourseDetailClient() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return <LoadingSkeleton />;
|
||||||
<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 (error || !course) return (
|
if (error || !course) return (
|
||||||
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
|
<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-destructive/10 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">
|
<AlertTriangle className="w-8 h-8 text-destructive" />
|
||||||
<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>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-2">{error || '课程不存在'}</h1>
|
<h1 className="text-xl font-bold text-foreground mb-2">{error || '课程不存在'}</h1>
|
||||||
<Link href="/courses" className="text-brand-600 hover:underline text-sm">返回课程列表</Link>
|
<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);
|
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 (
|
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`}>
|
<PayModal
|
||||||
<div className="p-4 border-b border-border">
|
paying={paying}
|
||||||
<Link href="/courses" className="text-xs text-muted-foreground hover:text-brand-600 mb-2 block">
|
qrCodeUrl={qrCodeUrl}
|
||||||
← 返回课程列表
|
onClose={() => { setPaying(false); setQrCodeUrl(''); }}
|
||||||
</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>
|
|
||||||
|
|
||||||
{sidebarOpen && (
|
<div className="flex h-[calc(100vh-4rem)] max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="fixed inset-0 bg-black/20 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
|
{/* Sidebar */}
|
||||||
)}
|
<aside className={`
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
fixed md:relative md:translate-x-0 z-30
|
||||||
<div className="sticky top-0 z-10 bg-card border-b border-border px-4 py-3 flex items-center gap-3">
|
w-72 bg-card border-r border-border
|
||||||
<button
|
overflow-y-auto flex-shrink-0 transition-transform
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
`}>
|
||||||
className="md:hidden p-1.5 rounded-lg hover:bg-muted"
|
<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">
|
||||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<ChevronLeft className="w-3 h-3" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
返回课程列表
|
||||||
</svg>
|
</Link>
|
||||||
</button>
|
<h2 className="font-semibold text-foreground text-sm line-clamp-2 mt-2">{course.title}</h2>
|
||||||
<div className="flex-1 min-w-0">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<h1 className="text-sm font-medium text-foreground truncate">
|
<BookOpen className="w-3 h-3 inline mr-1 align-text-bottom" />
|
||||||
{activeLesson?.title || course.title}
|
{course.chapters.length} 章 · {totalLessons} 课时
|
||||||
</h1>
|
</p>
|
||||||
</div>
|
</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">
|
<nav className="p-2">
|
||||||
{activeLesson ? (
|
{course.chapters.map((chapter, ci) => (
|
||||||
<article className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
<div key={chapter.id} className="mb-3">
|
||||||
<h1 className="text-2xl font-bold text-foreground mb-6">{activeLesson.title}</h1>
|
<div className="text-xs font-medium text-muted-foreground px-2 py-1.5">
|
||||||
<div className="prose prose-gray max-w-none">
|
{ci + 1}. {chapter.title}
|
||||||
{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>
|
|
||||||
</div>
|
</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>
|
</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>
|
||||||
)}
|
|
||||||
|
<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>
|
</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;
|
font-family: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Article body styles for markdown content */
|
||||||
|
.article-body {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body h1,
|
||||||
|
.article-body h2,
|
||||||
|
.article-body h3 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
padding-left: 0.5em;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: border-bottom-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body a:hover {
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body blockquote {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 0.75em 1em;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
border-radius: 0 0.5em 0.5em 0;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body blockquote p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body ul,
|
||||||
|
.article-body ol {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body li {
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body hr {
|
||||||
|
margin: 2em 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body code {
|
||||||
|
font-family: 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
padding: 0.125em 0.375em;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body pre {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 1em;
|
||||||
|
background: var(--muted);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body th,
|
||||||
|
.article-body td {
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-body th {
|
||||||
|
background: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
+102
-8
@@ -9,26 +9,120 @@ export const metadata: Metadata = {
|
|||||||
default: '宇之然 AI - AI 工具与知识社区',
|
default: '宇之然 AI - AI 工具与知识社区',
|
||||||
template: '%s | 宇之然 AI',
|
template: '%s | 宇之然 AI',
|
||||||
},
|
},
|
||||||
description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科等,让每个人都能用好 AI。',
|
description: '宇之然 AI 是面向大众化分领域用户的 AI 工具与知识社区,涵盖 AI 通识、提示词工程、智能体教程、模型百科、AI 沙盒实战等,让每个人都能用好 AI。',
|
||||||
keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然'],
|
keywords: ['AI', '人工智能', '学习', '提示词', '智能体', '大模型', '宇之然', 'AI工具', 'AI教程', 'AI沙盒', '提示词工程', 'AI社区', '知识库', '编程学习', 'AI工具返利', 'AI工具联盟', 'AI工具推荐', 'AI工具测评'],
|
||||||
icons: {
|
robots: {
|
||||||
icon: '/favicon.png',
|
index: true,
|
||||||
shortcut: '/favicon.png',
|
follow: true,
|
||||||
apple: '/icon.svg',
|
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: {
|
openGraph: {
|
||||||
type: 'website',
|
type: 'website',
|
||||||
locale: 'zh_CN',
|
locale: 'zh_CN',
|
||||||
siteName: '宇之然 AI',
|
siteName: '宇之然 AI',
|
||||||
title: '宇之然 AI - AI 工具与知识社区',
|
title: '宇之然 AI - AI 工具与知识社区',
|
||||||
description: '让每个人都能用好 AI',
|
description: '让每个人都能用好 AI - AI 工具指南、提示词库、智能体教程、模型百科、AI 沙盒实战',
|
||||||
url: SITE_URL,
|
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 }) {
|
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 (
|
return (
|
||||||
<html lang="zh-CN" suppressHydrationWarning>
|
<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">
|
<body className="min-h-screen flex flex-col">
|
||||||
<RootLayoutClient>{children}</RootLayoutClient>
|
<RootLayoutClient>{children}</RootLayoutClient>
|
||||||
<Script id="baidu-analytics" strategy="afterInteractive">
|
<Script id="baidu-analytics" strategy="afterInteractive">
|
||||||
@@ -43,4 +137,4 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag } from 'lucide-react';
|
|
||||||
import { useT } from '@/i18n';
|
|
||||||
|
|
||||||
const icons = [MessageSquare, Code, BarChart3, Sparkles, Target, ShoppingBag];
|
|
||||||
|
|
||||||
export default function ModelsPage() {
|
|
||||||
const t = useT();
|
|
||||||
|
|
||||||
const capabilities = [
|
|
||||||
{ key: 'capChat', link: '/sandbox', cta: t.models.goSandbox },
|
|
||||||
{ key: 'capCode', link: '/sandbox', cta: t.models.goSandbox },
|
|
||||||
{ key: 'capData', link: '/sandbox', cta: t.models.goSandbox },
|
|
||||||
{ key: 'capPrompt', link: '/prompts', cta: t.models.goPrompts },
|
|
||||||
{ key: 'capPractice', link: '/practices', cta: t.models.goPractice },
|
|
||||||
{ key: 'capMarketplace', link: '/marketplace', cta: t.models.goMarketplace },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
||||||
<div className="text-center mb-12">
|
|
||||||
<h1 className="text-3xl font-bold text-foreground">{t.models.title}</h1>
|
|
||||||
<p className="mt-3 text-muted-foreground max-w-2xl mx-auto">{t.models.desc}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{capabilities.map((cap, i) => {
|
|
||||||
const Icon = icons[i];
|
|
||||||
const title = t.models[cap.key as keyof typeof t.models] as string;
|
|
||||||
const desc = t.models[`${cap.key}Desc` as keyof typeof t.models] as string;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={cap.key}
|
|
||||||
href={cap.link}
|
|
||||||
className="group bg-card rounded-xl border border-border p-6 hover:border-brand-200 hover:shadow-sm transition-all"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-brand-50 dark:bg-brand-950 flex items-center justify-center mb-4 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 transition-colors">
|
|
||||||
<Icon className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-foreground mb-2">{title}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">{desc}</p>
|
|
||||||
<span className="text-sm font-medium text-brand-600 dark:text-brand-400 group-hover:underline">
|
|
||||||
{cap.cta} →
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+165
-53
@@ -3,7 +3,8 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { HomePageClient } from './home-client';
|
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 { Card } from '@/components/ui/card';
|
||||||
import { useT } from '@/i18n';
|
import { useT } from '@/i18n';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
@@ -12,38 +13,42 @@ export default function HomePage() {
|
|||||||
const t = useT();
|
const t = useT();
|
||||||
const [courses, setCourses] = useState<any[]>([]);
|
const [courses, setCourses] = useState<any[]>([]);
|
||||||
const [featuredTools, setFeaturedTools] = 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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
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}/courses?pageSize=3`).then(r => r.json()),
|
||||||
fetch(`${API_BASE}/public/stats`).then(r => r.json()),
|
fetch(`${API_BASE}/tools?pageSize=1`).then(r => r.json()),
|
||||||
fetch(`${API_BASE}/tools?isFeatured=true&pageSize=6`).then(r => r.json()),
|
fetch(`${API_BASE}/prompts?pageSize=1`).then(r => r.json()),
|
||||||
]).then(([courseData, statsData, toolsData]) => {
|
fetch(`${API_BASE}/practices?pageSize=1`).then(r => r.json()),
|
||||||
setCourses(courseData.items || []);
|
]).then(([toolsData, coursesData, toolsAll, promptsData, practicesData]) => {
|
||||||
setFeaturedTools(toolsData.items || []);
|
setFeaturedTools(toolsData.items || []);
|
||||||
setStats(statsData);
|
setCourses(coursesData.items || []);
|
||||||
}).catch(() => {});
|
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 = [
|
const statItems = [
|
||||||
{ value: stats ? `${stats.courses}+` : '50+', label: t.home.statTopics },
|
{ value: stats ? `${stats.tools}+` : '--', label: '收录工具', href: '/tools', icon: Wrench },
|
||||||
{ value: stats ? `${stats.prompts}+` : '200+', label: t.home.statPrompts },
|
{ value: stats ? `${stats.courses}+` : '--', label: '课程资源', href: '/courses', icon: BookOpen },
|
||||||
{ value: stats ? `${stats.tools}+` : '30+', label: t.home.statTools },
|
{ value: stats ? `${stats.prompts}+` : '--', label: '提示词库', href: '/prompts', icon: FileText },
|
||||||
{ value: stats ? `${stats.users / 1000 > 1 ? Math.round(stats.users / 100) * 100 + '+' : stats.users + '+'}` : '10,000+', label: t.home.statExplorers },
|
{ value: stats ? `${stats.practices}+` : '--', label: '练习场景', href: '/practices', icon: Puzzle },
|
||||||
];
|
|
||||||
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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomePageClient>
|
<HomePageClient>
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="relative overflow-hidden">
|
<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-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 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" />
|
<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">
|
<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">
|
<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" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
{t.home.badge}
|
精选AI工具 · 真实评测
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight">
|
<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">
|
<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>
|
</span>
|
||||||
<br />{t.home.heroRest}
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
|
<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]">
|
<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]">
|
||||||
<BookOpen className="w-5 h-5" />
|
<Wrench className="w-5 h-5" />
|
||||||
{t.home.startExplore}
|
浏览工具库
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</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">
|
<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">
|
||||||
{t.home.freeRegister}
|
<Bot className="w-5 h-5" />
|
||||||
|
沙盒体验
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
{statItems.map((s) => (
|
{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">
|
<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}
|
{s.value}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-sm text-muted-foreground">{s.label}</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">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,48 +105,151 @@ export default function HomePage() {
|
|||||||
{featuredTools.length > 0 && (
|
{featuredTools.length > 0 && (
|
||||||
<section className="py-16 bg-muted/30">
|
<section className="py-16 bg-muted/30">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-10">
|
<div className="flex items-center justify-between mb-10">
|
||||||
<h2 className="text-3xl font-bold">{t.home.featuredToolsTitle}</h2>
|
<div>
|
||||||
<p className="mt-2 text-muted-foreground">{t.home.featuredToolsDesc}</p>
|
<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>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{featuredTools.map((tool: any) => (
|
{featuredTools.map((tool: any) => {
|
||||||
<a key={tool.id} href={tool.url} target="_blank" rel="noopener noreferrer" className="block group">
|
const highlightMatch = tool.description?.match(/【推荐理由】\s*(.+?)(?:\n|$)/);
|
||||||
<Card className="p-4 hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800 transition-all group">
|
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="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">
|
<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" />
|
<Wrench className="w-5 h-5 text-brand-600 dark:text-brand-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold group-hover:text-brand-600 transition-colors truncate">{tool.name}</h3>
|
<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>
|
</div>
|
||||||
|
<ArrowRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
</a>
|
</Link>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="py-20">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="text-center mb-16">
|
<div className="text-center mb-16">
|
||||||
<h2 className="text-3xl font-bold">{t.home.whyTitle}</h2>
|
<h2 className="text-3xl font-bold">AI 能力导览</h2>
|
||||||
<p className="mt-4 text-lg text-muted-foreground">{t.home.whyDesc}</p>
|
<p className="mt-4 text-lg text-muted-foreground">探索 AI 能为你做什么,从对话写作到编程实战,找到适合你的场景</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 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">
|
{ icon: MessageSquare, title: '对话写作', desc: '与 AI 进行自然对话,获取灵感、润色文案、翻译语言', href: '/sandbox', cta: '打开沙盒' },
|
||||||
<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">
|
{ icon: Code, title: '编程辅助', desc: '生成代码、调试错误、解释算法,你的随身编程搭档', href: '/sandbox', cta: '打开沙盒' },
|
||||||
<feat.icon className="w-7 h-7 text-brand-600 dark:text-brand-400" />
|
{ icon: BarChart3, title: '数据分析', desc: '分析数据、生成图表、洞察趋势,让数据说话', href: '/sandbox', cta: '打开沙盒' },
|
||||||
</div>
|
{ icon: FileText, title: '提示词工程', desc: '学习编写高效提示词,掌握与 AI 沟通的最佳实践', href: '/prompts', cta: '浏览提示词库' },
|
||||||
<h3 className="text-lg font-semibold mb-2">{feat.title}</h3>
|
{ icon: GraduationCap, title: '技能实战', desc: '在真实场景中练习 AI 技能,获得即时评分反馈', href: '/practices', cta: '开始练习' },
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{feat.desc}</p>
|
{ icon: Puzzle, title: '工作流技能', desc: '购买即用的 AI 工作流技能,解决具体业务问题', href: '/marketplace', cta: '前往技能广场' },
|
||||||
</div>
|
].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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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[];
|
starters: string[]; tasks: SkillTask[]; tags: string[];
|
||||||
prerequisites?: string[];
|
prerequisites?: string[];
|
||||||
}
|
}
|
||||||
|
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
|
||||||
|
|
||||||
export default function SkillDetailPage() {
|
export default function SkillDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [skill, setSkill] = useState<Skill | null>(null);
|
const [skill, setSkill] = useState<Skill | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [links, setLinks] = useState<AffiliateLink[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!params.id) return;
|
if (!params.id) return;
|
||||||
@@ -29,6 +31,24 @@ export default function SkillDetailPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [params.id]);
|
}, [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 (
|
if (loading) return (
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<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" />
|
<Skeleton className="h-8 w-48 mb-6" />
|
||||||
@@ -108,6 +128,29 @@ export default function SkillDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/tools`)
|
fetch(`${API_BASE}/tools?categoryId=7`)
|
||||||
.then(r => r.json()).then(data => setTools(data.items || []))
|
.then(r => r.json()).then(data => setTools(data.items || []))
|
||||||
.catch(() => {}).finally(() => setLoading(false));
|
.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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="mb-10">
|
<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">{[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">
|
<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">
|
<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">
|
<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">
|
<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-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{tool.name}</h3>
|
<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" />}
|
{tool.isFeatured && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<ExternalLink className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-1" />
|
<ExternalLink className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-1" />
|
||||||
</div>
|
</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">
|
<div className="flex gap-2 mt-2">
|
||||||
{tool.tags?.split(',').slice(0, 2).map(tag => (
|
{tool.tags?.split(',').slice(0, 2).map(tag => (
|
||||||
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
|
<Badge key={tag} variant="secondary">{tag.trim()}</Badge>
|
||||||
@@ -65,7 +77,8 @@ export default function ToolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</a>
|
</a>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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="/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="/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="/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>
|
<li><Link href="/tools" className="text-sm text-muted-foreground hover:text-foreground transition-colors">{t.footer.aiTools}</Link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,35 +53,18 @@ export function Header() {
|
|||||||
const { lang, setLang } = useLang();
|
const { lang, setLang } = useLang();
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
|
||||||
const mainNavItems = [
|
const navItems = [
|
||||||
{ href: '/', label: t.nav.home },
|
{ 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: '/sandbox', label: t.nav.sandbox },
|
||||||
{ href: '/practices', label: t.nav.practices },
|
{ 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: '/prompts', label: t.nav.prompts },
|
||||||
{ href: '/models', label: t.nav.models },
|
{ href: '/resources', label: '云资源' },
|
||||||
{ href: '/tools', label: t.nav.tools },
|
{ href: '/marketplace', label: t.nav.marketplace },
|
||||||
{ href: '/contents', label: t.nav.articles },
|
{ 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) {
|
function isActive(href: string) {
|
||||||
if (href === '/') return pathname === '/';
|
if (href === '/') return pathname === '/';
|
||||||
return pathname.startsWith(href);
|
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>
|
<span className="text-lg text-muted-foreground hidden sm:inline">{t.brand.suffix}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center gap-1">
|
<nav className="hidden md:flex items-center gap-1 flex-wrap">
|
||||||
{mainNavItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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)
|
isActive(item.href)
|
||||||
? 'bg-accent text-foreground font-semibold'
|
? 'bg-accent text-foreground font-semibold'
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||||
@@ -122,46 +105,6 @@ export function Header() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
@@ -171,7 +114,7 @@ export function Header() {
|
|||||||
name="q"
|
name="q"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t.common.search}
|
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>
|
</form>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@@ -227,22 +170,7 @@ export function Header() {
|
|||||||
|
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
|
<nav className="md:hidden pb-4 border-t border-border pt-4 animate-fade-in">
|
||||||
{mainNavItems.map((item) => (
|
{navItems.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) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
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' },
|
brand: { name: 'Yuzhiran', suffix: 'AI' },
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const zh = {
|
|||||||
courses: { title: '专题', desc: '系统化探索 AI,从入门到精通', empty: '暂无专题内容', free: '免费', paid: '付费', moduleCount: '{n} 模块' },
|
courses: { title: '专题', desc: '系统化探索 AI,从入门到精通', empty: '暂无专题内容', free: '免费', paid: '付费', moduleCount: '{n} 模块' },
|
||||||
search: { title: '搜索结果', placeholder: '搜索专题、提示词、工具、文章...', emptyHint: '输入关键词搜索', noResults: '未找到与 "{q}" 相关的结果', resultsCount: '找到 {n} 个结果', groupCourse: '专题', groupPrompt: '提示词', groupTool: 'AI 工具', groupContent: '文章' },
|
search: { title: '搜索结果', placeholder: '搜索专题、提示词、工具、文章...', emptyHint: '输入关键词搜索', noResults: '未找到与 "{q}" 相关的结果', resultsCount: '找到 {n} 个结果', groupCourse: '专题', groupPrompt: '提示词', groupTool: 'AI 工具', groupContent: '文章' },
|
||||||
tools: { title: 'AI 工具库', desc: '收录优质 AI 工具,助力工作效率提升' },
|
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工具' },
|
discover: { desc: '探索热门内容和精选推荐', hotCourses: '热门课程', hotPrompts: '热门提示词', hotPosts: '热门讨论', viewCount: '{n} 浏览', likeCount: '{n} 点赞', postStats: '❤️ {likes} 点赞 · 👁 {views} 浏览', toolsTitle: '工具推荐', toolsDesc: '精选热门AI工具' },
|
||||||
circles: { title: '圈子', desc: '按领域划分的垂直讨论区', empty: '暂无圈子', back: '返回发现', members: '人', posts: '帖' },
|
circles: { title: '圈子', desc: '按领域划分的垂直讨论区', empty: '暂无圈子', back: '返回发现', members: '人', posts: '帖' },
|
||||||
brand: { name: '宇之然', suffix: 'AI' },
|
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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>宇之然 AI</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user